Skip to content

Commit 6d07d59

Browse files
Address review feedback: bound buffered writes and scope backpressure to yamux
- Reinstate maxBufferedConnectionWrites (10 MiB default) and its public API on StreamMuxerProtocol.getYamux. The limit is now enforced against the actual bytes pending in the child channel outbound buffers, covering both send-window stalls and parent TCP backpressure stalls (the latter was never bounded before). On overflow the offending stream is reset, its pending writes are failed and the stream channel is closed, releasing the buffers. - Move all writability/backpressure logic (parent writability gating, write buffer watermarks, user-defined writability flags) from the common classes into YamuxHandler. AbstractMuxHandler and MuxChannel keep only neutral extension points, so Mplex behaves exactly as before. - Remove the redundant retainedPendingWrite ref-counting in MuxChannel: the ChannelOutboundBuffer owns pending messages and releases them on remove/fail/close on every path. - Clamp send window arithmetic so cumulative window updates from a misbehaving remote cannot overflow the window counter. - Fix the deferred-disconnect counter not being decremented when a pending write fails, which could leave the FIN frame unsent.
1 parent 16ebd01 commit 6d07d59

6 files changed

Lines changed: 233 additions & 95 deletions

File tree

libp2p/src/main/kotlin/io/libp2p/core/mux/StreamMuxerProtocol.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.libp2p.core.multistream.MultistreamProtocol
44
import io.libp2p.core.multistream.ProtocolBinding
55
import io.libp2p.mux.mplex.MplexStreamMuxer
66
import io.libp2p.mux.yamux.DEFAULT_ACK_BACKLOG_LIMIT
7+
import io.libp2p.mux.yamux.DEFAULT_MAX_BUFFERED_CONNECTION_WRITES
78
import io.libp2p.mux.yamux.YamuxStreamMuxer
89

910
fun interface StreamMuxerProtocol {
@@ -22,17 +23,24 @@ fun interface StreamMuxerProtocol {
2223
}
2324

2425
/**
26+
* @param maxBufferedConnectionWrites the maximum amount of bytes which may be buffered (pending)
27+
* across the write buffers of all streams of a single connection. When the limit is overflowed,
28+
* the stream attempting to write is reset
2529
* @param ackBacklogLimit the maximum amount of opened streams per connection which have not been acknowledged
2630
*/
2731
@JvmStatic
2832
@JvmOverloads
29-
fun getYamux(ackBacklogLimit: Int = DEFAULT_ACK_BACKLOG_LIMIT): StreamMuxerProtocol {
33+
fun getYamux(
34+
maxBufferedConnectionWrites: Int = DEFAULT_MAX_BUFFERED_CONNECTION_WRITES,
35+
ackBacklogLimit: Int = DEFAULT_ACK_BACKLOG_LIMIT
36+
): StreamMuxerProtocol {
3037
return StreamMuxerProtocol { multistreamProtocol, protocols ->
3138
YamuxStreamMuxer(
3239
multistreamProtocol.createMultistream(
3340
protocols
3441
).toStreamHandler(),
3542
multistreamProtocol,
43+
maxBufferedConnectionWrites,
3644
ackBacklogLimit
3745
)
3846
}

libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/AbstractMuxHandler.kt

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,6 @@ private val log = LoggerFactory.getLogger(AbstractMuxHandler::class.java)
1717
abstract class AbstractMuxHandler<TData>() :
1818
ChannelInboundHandlerAdapter() {
1919

20-
private companion object {
21-
const val PARENT_CHANNEL_WRITABILITY_INDEX = 1
22-
const val MUXER_WRITABILITY_INDEX = 2
23-
}
24-
2520
private val streamMap: MutableMap<MuxId, MuxChannel<TData>> = mutableMapOf()
2621
var ctx: ChannelHandlerContext? = null
2722
private val activeFuture = CompletableFuture<Void>()
@@ -89,11 +84,12 @@ abstract class AbstractMuxHandler<TData>() :
8984
*/
9085
abstract fun releaseMessage(msg: TData)
9186

92-
protected open fun isChildWritable(child: MuxChannel<TData>): Boolean = true
93-
94-
fun canWriteChild(child: MuxChannel<TData>): Boolean =
95-
getChannelHandlerContext().channel().isWritable && isChildWritable(child)
96-
87+
/**
88+
* Writes the child channel data to the underlying connection.
89+
* Returns the number of consumed bytes which may be less than the readable bytes of [data]
90+
* (including 0) when the muxer can't write at the moment (e.g. flow control window exhausted).
91+
* Unconsumed data remains buffered in the child channel and is written again on [retryChildWrite]
92+
*/
9793
abstract fun onChildWrite(child: MuxChannel<TData>, data: TData): Int
9894

9995
fun flushChildWrites() {
@@ -108,32 +104,22 @@ abstract class AbstractMuxHandler<TData>() :
108104
streamMap.values.forEach { it.retryWrite() }
109105
}
110106

111-
protected fun refreshChildWritability(id: MuxId) {
112-
streamMap[id]?.let { refreshChildWritability(it) }
107+
protected fun setChildMuxWritability(id: MuxId, index: Int, writable: Boolean) {
108+
streamMap[id]?.setMuxWritability(index, writable)
113109
}
114110

115-
protected open fun onChildRegistered(child: MuxChannel<TData>) {
116-
refreshChildWritability(child)
111+
protected fun setAllChildMuxWritability(index: Int, writable: Boolean) {
112+
streamMap.values.forEach { it.setMuxWritability(index, writable) }
117113
}
118114

119-
private fun refreshChildWritability(child: MuxChannel<TData>) {
120-
child.setMuxWritability(
121-
PARENT_CHANNEL_WRITABILITY_INDEX,
122-
getChannelHandlerContext().channel().isWritable
123-
)
124-
child.setMuxWritability(MUXER_WRITABILITY_INDEX, isChildWritable(child))
125-
}
115+
/**
116+
* Total bytes (including a small per-message accounting overhead) buffered
117+
* in the outbound buffers of all child channels
118+
*/
119+
protected fun totalChildPendingWriteBytes(): Long =
120+
streamMap.values.sumOf { it.pendingWriteBytes() }
126121

127-
override fun channelWritabilityChanged(ctx: ChannelHandlerContext) {
128-
val parentWritable = ctx.channel().isWritable
129-
streamMap.values.forEach {
130-
it.setMuxWritability(PARENT_CHANNEL_WRITABILITY_INDEX, parentWritable)
131-
}
132-
if (parentWritable) {
133-
retryAllChildWrites()
134-
}
135-
super.channelWritabilityChanged(ctx)
136-
}
122+
protected open fun onChildRegistered(child: MuxChannel<TData>) {}
137123

138124
protected fun onRemoteOpen(id: MuxId) {
139125
val initializer = inboundInitializer

libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/MuxChannel.kt

Lines changed: 33 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import io.libp2p.etc.util.netty.AbstractChildChannel
55
import io.netty.buffer.ByteBuf
66
import io.netty.channel.ChannelMetadata
77
import io.netty.channel.ChannelOutboundBuffer
8-
import io.netty.util.ReferenceCountUtil
98
import java.net.SocketAddress
109

1110
/**
@@ -21,8 +20,7 @@ class MuxChannel<TData>(
2120
var remoteDisconnected = false
2221
var localDisconnected = false
2322
private var localDisconnectSent = false
24-
private var disconnectFlushedMessages = 0
25-
private var retainedPendingWrite: Any? = null
23+
private var pendingDisconnectWrites = 0
2624

2725
override fun metadata(): ChannelMetadata = ChannelMetadata(true)
2826
override fun localAddress0() =
@@ -46,77 +44,77 @@ class MuxChannel<TData>(
4644
unsafe().outboundBuffer()?.setUserDefinedWritability(index, writable)
4745
}
4846

47+
fun pendingWriteBytes(): Long = unsafe().outboundBuffer()?.totalPendingWriteBytes() ?: 0L
48+
4949
@Suppress("SwallowedException")
5050
override fun doWrite(buf: ChannelOutboundBuffer) {
5151
while (true) {
5252
val msg = buf.current() ?: break
53-
if (localDisconnected && disconnectFlushedMessages == 0) {
53+
if (localDisconnected && pendingDisconnectWrites == 0) {
5454
// Must not throw from doWrite — exceptions escape uncaught to the Netty event loop.
5555
// Wrap buf.remove() defensively: in some Netty versions promise listeners triggered
5656
// by buf.remove() can propagate back through it.
5757
try {
5858
buf.remove(ConnectionClosedException("The stream was closed for writing locally: $id"))
5959
} catch (e: Throwable) { }
60-
releaseRetainedPendingWrite(msg)
6160
continue
6261
}
63-
if (!parent.canWriteChild(this)) {
64-
retainPendingWrite(msg)
65-
break
66-
}
6762
try {
6863
@Suppress("UNCHECKED_CAST")
69-
val consumedBytes = parent.onChildWrite(this, msg as TData)
70-
if (consumedBytes <= 0) {
71-
retainPendingWrite(msg)
72-
break
73-
}
74-
val currentMessageComplete = msg !is ByteBuf || consumedBytes >= msg.readableBytes()
75-
if (!currentMessageComplete) {
76-
retainPendingWrite(msg)
77-
}
78-
if (msg is ByteBuf) {
64+
msg as TData
65+
val complete: Boolean
66+
if (msg is ByteBuf && msg.isReadable) {
67+
val consumedBytes = parent.onChildWrite(this, msg)
68+
if (consumedBytes <= 0) {
69+
// the muxer can't write at the moment: the message stays buffered
70+
// (the outbound buffer owns it) until the parent retries this write
71+
break
72+
}
73+
complete = consumedBytes >= msg.readableBytes()
7974
buf.removeBytes(consumedBytes.toLong())
8075
} else {
76+
parent.onChildWrite(this, msg)
77+
complete = true
8178
buf.remove()
8279
}
8380
parent.flushChildWrites()
84-
if (currentMessageComplete) {
85-
releaseRetainedPendingWrite(msg)
86-
}
87-
if (localDisconnected && currentMessageComplete) {
88-
disconnectFlushedMessages--
89-
if (disconnectFlushedMessages == 0) {
90-
finishLocalDisconnect()
91-
continue
92-
}
81+
if (!complete) {
82+
// partially written: wait for a retry before sending the rest
83+
break
9384
}
85+
onPendingDisconnectWriteCompleted()
9486
} catch (cause: Throwable) {
9587
buf.remove(cause)
96-
releaseRetainedPendingWrite(msg)
88+
onPendingDisconnectWriteCompleted()
9789
}
9890
}
9991
}
10092

10193
override fun doDisconnect() {
10294
localDisconnected = true
103-
disconnectFlushedMessages = unsafe().outboundBuffer()?.size() ?: 0
104-
if (disconnectFlushedMessages == 0) {
95+
// delay the local disconnect until all writes flushed before the disconnect are sent
96+
pendingDisconnectWrites = unsafe().outboundBuffer()?.size() ?: 0
97+
if (pendingDisconnectWrites == 0) {
10598
finishLocalDisconnect()
10699
}
107100
}
108101

102+
private fun onPendingDisconnectWriteCompleted() {
103+
if (localDisconnected && pendingDisconnectWrites > 0) {
104+
pendingDisconnectWrites--
105+
if (pendingDisconnectWrites == 0) {
106+
finishLocalDisconnect()
107+
}
108+
}
109+
}
110+
109111
fun onRemoteDisconnected() {
110112
pipeline().fireUserEventTriggered(RemoteWriteClosed)
111113
remoteDisconnected = true
112114
closeIfBothDisconnected()
113115
}
114116

115117
override fun doClose() {
116-
retainedPendingWrite?.let {
117-
retainedPendingWrite = null
118-
ReferenceCountUtil.release(it)
119-
}
120118
super.doClose()
121119
parent.onClosed(this)
122120
}
@@ -138,20 +136,6 @@ class MuxChannel<TData>(
138136
deactivate()
139137
closeIfBothDisconnected()
140138
}
141-
142-
private fun retainPendingWrite(msg: Any) {
143-
if (retainedPendingWrite !== msg) {
144-
retainedPendingWrite = msg
145-
ReferenceCountUtil.retain(msg)
146-
}
147-
}
148-
149-
private fun releaseRetainedPendingWrite(msg: Any) {
150-
if (retainedPendingWrite === msg) {
151-
retainedPendingWrite = null
152-
ReferenceCountUtil.release(msg)
153-
}
154-
}
155139
}
156140

157141
data class MultiplexSocketAddress(val parentAddress: SocketAddress, val streamId: MuxId) : SocketAddress() {

libp2p/src/main/kotlin/io/libp2p/mux/yamux/YamuxHandler.kt

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean
1818
import java.util.concurrent.atomic.AtomicInteger
1919
import kotlin.properties.Delegates
2020

21+
const val DEFAULT_MAX_BUFFERED_CONNECTION_WRITES = 10 * 1024 * 1024 // 10 MiB
2122
const val DEFAULT_MAX_BUFFERED_STREAM_WRITES = 64 * 1024
2223
const val DEFAULT_ACK_BACKLOG_LIMIT = 256
2324

@@ -29,10 +30,16 @@ open class YamuxHandler(
2930
ready: CompletableFuture<StreamMuxer.Session>?,
3031
inboundStreamHandler: StreamHandler<*>,
3132
private val connectionInitiator: Boolean,
33+
private val maxBufferedConnectionWrites: Int,
3234
private val ackBacklogLimit: Int,
3335
private val initialWindowSize: Int = INITIAL_WINDOW_SIZE
3436
) : MuxHandler(ready, inboundStreamHandler) {
3537

38+
private companion object {
39+
const val PARENT_WRITABILITY_INDEX = 1
40+
const val SEND_WINDOW_WRITABILITY_INDEX = 2
41+
}
42+
3643
private val childWriteBufferWaterMark =
3744
WriteBufferWaterMark(DEFAULT_MAX_BUFFERED_STREAM_WRITES / 2, DEFAULT_MAX_BUFFERED_STREAM_WRITES)
3845

@@ -45,9 +52,9 @@ open class YamuxHandler(
4552
val receiveWindowSize = AtomicInteger(initialWindowSize)
4653
var closedForWriting by Delegates.writeOnce(false)
4754

48-
fun dispose() {}
49-
50-
fun isWritable(): Boolean = sendWindowSize.get() > 0
55+
fun refreshSendWindowWritability() {
56+
setChildMuxWritability(id, SEND_WINDOW_WRITABILITY_INDEX, sendWindowSize.get() > 0)
57+
}
5158

5259
fun handleFrameRead(msg: YamuxFrame) {
5360
handleFlags(msg)
@@ -78,8 +85,13 @@ open class YamuxHandler(
7885

7986
private fun handleWindowUpdate(msg: YamuxFrame) {
8087
val delta = msg.length.toInt()
81-
sendWindowSize.addAndGet(delta)
82-
refreshChildWritability(id)
88+
// clamp the result so a misbehaving remote can't overflow the window counter
89+
sendWindowSize.getAndUpdate { current ->
90+
(current.toLong() + delta)
91+
.coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong())
92+
.toInt()
93+
}
94+
refreshSendWindowWritability()
8395
retryChildWrite(id)
8496
}
8597

@@ -129,7 +141,7 @@ open class YamuxHandler(
129141
ctx.write(YamuxFrame(id, YamuxType.DATA, YamuxFlag.NONE, length.toLong(), slicedData))
130142
written += length
131143
}
132-
refreshChildWritability(id)
144+
refreshSendWindowWritability()
133145
return written
134146
}
135147

@@ -173,8 +185,6 @@ open class YamuxHandler(
173185
}
174186

175187
override fun channelUnregistered(ctx: ChannelHandlerContext?) {
176-
streamHandlers.values.forEach { it.dispose() }
177-
178188
if (!goAwayPromise.isDone) {
179189
goAwayPromise.completeExceptionally(ConnectionClosedException("Connection was closed without Go Away message"))
180190
}
@@ -215,16 +225,46 @@ open class YamuxHandler(
215225
}
216226
}
217227

218-
override fun isChildWritable(child: MuxChannel<ByteBuf>): Boolean =
219-
streamHandlers[child.id]?.isWritable() == true
220-
221228
override fun onChildRegistered(child: MuxChannel<ByteBuf>) {
222229
child.config().setWriteBufferWaterMark(childWriteBufferWaterMark)
223-
super.onChildRegistered(child)
230+
child.setMuxWritability(PARENT_WRITABILITY_INDEX, getChannelHandlerContext().channel().isWritable)
231+
streamHandlers[child.id]?.refreshSendWindowWritability()
232+
}
233+
234+
override fun channelWritabilityChanged(ctx: ChannelHandlerContext) {
235+
val parentWritable = ctx.channel().isWritable
236+
setAllChildMuxWritability(PARENT_WRITABILITY_INDEX, parentWritable)
237+
if (parentWritable) {
238+
retryAllChildWrites()
239+
}
240+
super.channelWritabilityChanged(ctx)
224241
}
225242

226243
override fun onChildWrite(child: MuxChannel<ByteBuf>, data: ByteBuf): Int {
227-
return getStreamHandlerOrThrow(child.id).sendData(data)
244+
val streamHandler = getStreamHandlerOrThrow(child.id)
245+
if (!getChannelHandlerContext().channel().isWritable) {
246+
// propagate the connection backpressure to the stream:
247+
// don't queue more frames on the parent channel
248+
verifyBufferedWritesLimit(child, 0)
249+
return 0
250+
}
251+
val written = streamHandler.sendData(data)
252+
if (written < data.readableBytes()) {
253+
verifyBufferedWritesLimit(child, written)
254+
}
255+
return written
256+
}
257+
258+
private fun verifyBufferedWritesLimit(child: MuxChannel<ByteBuf>, justWrittenBytes: Int) {
259+
val bufferedBytes = totalChildPendingWriteBytes() - justWrittenBytes
260+
if (bufferedBytes > maxBufferedConnectionWrites) {
261+
onLocalClose(child)
262+
// close the channel after the pending writes have been failed by the throw below
263+
child.eventLoop().execute { child.closeImpl() }
264+
throw WriteBufferOverflowMuxerException(
265+
"Overflowed send buffer ($bufferedBytes/$maxBufferedConnectionWrites). Last stream attempting to write: ${child.id}"
266+
)
267+
}
228268
}
229269

230270
override fun onLocalOpen(child: MuxChannel<ByteBuf>) {
@@ -261,7 +301,7 @@ open class YamuxHandler(
261301
}
262302

263303
override fun onChildClosed(child: MuxChannel<ByteBuf>) {
264-
streamHandlers.remove(child.id)?.dispose()
304+
streamHandlers.remove(child.id)
265305
}
266306

267307
private fun handlePing(msg: YamuxFrame) {

0 commit comments

Comments
 (0)