Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/main/java/org/tron/p2p/connection/Channel.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.netty.handler.codec.protobuf.ProtobufVarint32LengthFieldPrepender;
import io.netty.handler.timeout.ReadTimeoutException;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutException;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
Expand Down Expand Up @@ -83,6 +84,7 @@ public class Channel {
@Getter
private long avgLatency;
private long count;
private final WriteBufferHandler writeBufferHandler = new WriteBufferHandler(this);

/** @deprecated Use {@link #isOutbound()} to query connection direction. */
@Deprecated
Expand Down Expand Up @@ -118,6 +120,7 @@ public void init(ChannelPipeline pipeline, String nodeId, boolean nodeDetectMode
pipeline.addLast(TrafficStats.tcp);
pipeline.addLast("protoPrepend", new ProtobufVarint32LengthFieldPrepender());
pipeline.addLast("protoDecode", new P2pProtobufVarint32FrameDecoder(this));
pipeline.addLast("writeTimeoutHandler", writeBufferHandler);
pipeline.addLast("messageHandler", messageHandler);
}

Expand All @@ -131,6 +134,7 @@ public void processException(Throwable throwable) {
}
SocketAddress address = ctx.channel().remoteAddress();
if (throwable instanceof ReadTimeoutException
|| throwable instanceof WriteTimeoutException
|| throwable instanceof IOException
|| throwable instanceof CorruptedFrameException) {
log.warn("Close peer {}, reason: {}", address, throwable.getMessage());
Expand Down Expand Up @@ -217,7 +221,7 @@ public void send(byte[] data) {
}

ByteBuf byteBuf = Unpooled.wrappedBuffer(data);
ctx.writeAndFlush(byteBuf).addListener((ChannelFutureListener) future -> {
writeBufferHandler.writeAndFlush(ctx, byteBuf).addListener((ChannelFutureListener) future -> {
if (!future.isSuccess() && !isDisconnect) {
log.warn("Send to {} failed, message-type:{}, cause:{}",
ctx.channel().remoteAddress(), ByteArray.byte2int(type),
Expand Down
139 changes: 139 additions & 0 deletions src/main/java/org/tron/p2p/connection/WriteBufferHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package org.tron.p2p.connection;

import com.google.protobuf.CodedOutputStream;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import io.netty.handler.timeout.WriteTimeoutHandler;
import java.nio.channels.ClosedChannelException;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;

/** Bounds pending TCP writes and closes connections whose writes stall. */
@Slf4j(topic = "net")
final class WriteBufferHandler extends WriteTimeoutHandler {

private static final Budget GLOBAL_BUDGET = new Budget(200L * 1024 * 1024, 500L * 1024 * 1024);

private final Channel channel;
private final Budget budget;
// Guarded by budget, including sends originating outside the channel's event loop.
private long pendingBytes;
private boolean closing;

WriteBufferHandler(Channel channel) {
this(channel, GLOBAL_BUDGET);
}

WriteBufferHandler(Channel channel, Budget budget) {
super(10);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD] Validate the 10-second write timeout against slow but progressing peers.

WriteTimeoutHandler enforces a deadline for each write, which is not reset by partial output progress. Legitimate bandwidth-limited peers may therefore continue receiving data but still be disconnected when writes queued behind a sync burst time out.

Please add socket-level tests using representative compressed sync batches and a continuously reading, throttled receiver, alongside the existing non-reading case.

Please clarify whether disconnecting such progressing peers is intentional and why the 10-second deadline is appropriate for supported deployments.

If longer progressing writes should be supported, consider a configurable timeout or bounded application-level backpressure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current policy intentionally limits each write operation to 10 seconds. Even if the connection is making partial write progress, it will be closed if a write remains incomplete beyond that deadline, limiting how long pending outbound data can retain resources.

A slow but continuously reading peer timing out is therefore consistent with this protection policy. We would prefer to retain the current 10-second setting without extending the deadline based on partial write progress.

this.channel = channel;
this.budget = budget;
}

ChannelFuture writeAndFlush(ChannelHandlerContext ctx, ByteBuf data) {
// Reserve before Netty can enqueue a write task on the event loop. Include the frame prefix
// that protoPrepend adds later, after the payload has already been compressed by Channel.send.
long size = (long) data.readableBytes()
+ CodedOutputStream.computeUInt32SizeNoTag(data.readableBytes());
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD] The default prepender can allocate substantially more than these counted bytes: it encodes into a default ioBuffer() and ensureWritable grows it, so e.g. a 1 MiB + 1 B payload holds a 2 MiB direct buffer. Consider overriding allocateBuffer() in a small prepender subclass to preallocate the framed length, using CodedOutputStream.computeUInt32SizeNoTag() for the prefix (Netty's computeRawVarint32Size() is package-private). This sizes the buffer to the frame instead of rounding it up to the next power of two; the budget would still count framed bytes rather than total memory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current thresholds control the total number of pending framed bytes; they do not represent an upper bound on actual memory usage. Preallocating based on the framed length could reduce excess buffer capacity, but that would be a further optimization. I would prefer to keep the current implementation in this PR.

ChannelPromise promise = ctx.newPromise();
promise.setUncancellable();
if (!budget.reserve(this, size)) {
data.release();
return promise.setFailure(new ClosedChannelException());
}
promise.addListener(future -> budget.release(this, size));
if (channel.isDisconnect()) {
data.release();
return promise.setFailure(new ClosedChannelException());
}
return ctx.writeAndFlush(data, promise);
}

private void disconnect() {
log.warn("Close peer {}, pending outbound data limit exceeded",
channel.getInetSocketAddress());
channel.close();
Comment on lines +55 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit (non-blocking)]: Consider following the existing disconnect paths by sending a best-effort P2pDisconnectMessage with an appropriate reason before closing, and removing this WARN. The victim is already marked closing, so a normal channel.send(...) would be rejected by the budget check. Any notification should account for that without admitting further business writes or delaying resource release; delivery cannot be guaranteed on a backlogged connection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not send a Disconnect message here because outbound data on the connection is already heavily backlogged, so appending a disconnect message would not get it delivered. We therefore close the connection directly to release resources. The connection has also already been marked as closing. We keep the WARN to record the local reason for closing, making it possible to distinguish outbound-buffer eviction from a normal disconnect.

}

/** Shared by TCP connections; the global limit triggers eviction rather than blocking I/O. */
static final class Budget {

private final long peerLimit;
private final long globalLimit;
private final Set<WriteBufferHandler> peers = new LinkedHashSet<>();
private long pendingBytes;
private WriteBufferHandler evicting;

Budget(long peerLimit, long globalLimit) {
this.peerLimit = peerLimit;
this.globalLimit = globalLimit;
}

private boolean reserve(WriteBufferHandler handler, long size) {
WriteBufferHandler victim;
synchronized (this) {
if (handler.closing || handler.channel.isDisconnect()) {
return false;
}
handler.pendingBytes += size;
pendingBytes += size;
peers.add(handler);
if (handler.pendingBytes > peerLimit) {
handler.closing = true;
victim = handler;
if (evicting == null) {
evicting = handler;
}
} else {
victim = selectVictim();
}
}
// Closing may immediately complete writes and re-enter release(). Never close under the lock.
if (victim != null) {
victim.disconnect();
}
return true;
}

private void release(WriteBufferHandler handler, long size) {
WriteBufferHandler victim;
synchronized (this) {
handler.pendingBytes -= size;
pendingBytes -= size;
if (handler.pendingBytes == 0) {
peers.remove(handler);
if (evicting == handler) {
evicting = null;
}
}
victim = selectVictim();
}
if (victim != null) {
victim.disconnect();
}
}

// Called with the budget lock held. Wait for the current victim's writes to be released
// before selecting another, even if multiple producers observe the global limit concurrently.
private WriteBufferHandler selectVictim() {
if (evicting != null || pendingBytes < globalLimit) {
return null;
}
WriteBufferHandler largest = null;
for (WriteBufferHandler candidate : peers) {
if (!candidate.closing && !candidate.channel.isDisconnect()
&& (largest == null || candidate.pendingBytes > largest.pendingBytes)) {
largest = candidate;
}
}
if (largest != null) {
largest.closing = true;
evicting = largest;
}
return largest;
}
}
}
51 changes: 51 additions & 0 deletions src/test/java/org/tron/p2p/connection/ChannelWriteTimeoutTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.tron.p2p.connection;

import io.netty.channel.ChannelOutboundBuffer;
import io.netty.channel.embedded.EmbeddedChannel;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
import org.junit.Assert;
import org.junit.Test;
import org.tron.p2p.connection.message.keepalive.PingMessage;

public class ChannelWriteTimeoutTest {

@Test
public void stalledWriteClosesConnectionAfterTenSeconds() throws Exception {
EmbeddedChannel netty = new EmbeddedChannel() {
@Override
protected void doWrite(ChannelOutboundBuffer buffer) {
// A socket whose remote peer does not read: leave writes pending in Netty.
}
};
netty.freezeTime();
Channel channel = new Channel();
channel.init(netty.pipeline(), "", false);
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 18888);
set(channel, "ctx", netty.pipeline().lastContext());
set(channel, "inetSocketAddress", address);
set(channel, "inetAddress", address.getAddress());
try {
channel.send(new PingMessage());
netty.advanceTimeBy(9, TimeUnit.SECONDS);
netty.runScheduledPendingTasks();
Assert.assertTrue(netty.isOpen());

netty.advanceTimeBy(1, TimeUnit.SECONDS);
netty.runScheduledPendingTasks();
Assert.assertFalse("a pending write must time out even without a business queue",
netty.isOpen());
Assert.assertTrue(channel.isDisconnect());
} finally {
netty.finishAndReleaseAll();
ChannelManager.getBannedNodes().invalidate(address.getAddress());
}
}

private static void set(Channel channel, String name, Object value) throws Exception {
Field field = Channel.class.getDeclaredField(name);
field.setAccessible(true);
field.set(channel, value);
}
}
Loading