Repository navigation
fix(net): add outbound buffer limits and write timeouts #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| /** 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; | ||
| } | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
WriteTimeoutHandlerenforces 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.
There was a problem hiding this comment.
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.