aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 11:06:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 11:06:53 +0200
commit0ead6a321fbe3cf97e22f6fee348f68b09bd53a8 (patch)
treeada17e460b9c8ce3ffc46211845f57f8209148de
parentc642acaa812ad347d9dbf604bf391bbed970d987 (diff)
downloadmeshbay-0ead6a321fbe3cf97e22f6fee348f68b09bd53a8.tar.gz
fix(node): stop aiortc's SCTP sender leaking bytes in flight
A chunk gap-acked then resent after T3 stayed counted for good; enough of them and the node sent nothing more on that connection. The photo backup stalled on it after a few dozen photos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/core.py1
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/sctp_fix.py32
-rw-r--r--packages/meshbay-node/tests/test_sctp_fix.py59
3 files changed, 92 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/core.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/core.py
index ae3f0e1..a44f097 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/core.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/core.py
@@ -14,6 +14,7 @@ from meshbay_common.protocol import MNP
from meshbay_node import ops
from meshbay_node import transfers as transfers_mod
+from meshbay_node.transport.webrtc import sctp_fix # noqa: F401 (patches aiortc on import)
from meshbay_node.transport.webrtc.channel import (
_REPLY_TO,
_DataChannelBuffer,
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/sctp_fix.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/sctp_fix.py
new file mode 100644
index 0000000..0d45a7d
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/sctp_fix.py
@@ -0,0 +1,32 @@
+"""A fix to aiortc's SCTP sender, applied on import (aiortc 1.15).
+
+aiortc counts the bytes in flight and sends nothing new while that count is at
+or above the congestion window. A chunk the peer acknowledged in a gap block is
+marked `_acked` and taken out of the count. If the T3 timer then expires, every
+chunk still queued is sent again, that one included, and goes back into the
+count with `_acked` still set. When the cumulative ack finally covers it, the
+SACK handler skips it because it reads as acknowledged already, and its bytes
+stay counted for the life of the association.
+
+Each such loss leaks a chunk. On a lossy path (a phone on Wi-Fi) the leak grows
+past the window, which T3 itself keeps small. From then on the node sends
+nothing on that connection: nothing is outstanding, so no timer runs to unstick
+it, and the association still reads as established. The peer keeps talking and
+the node keeps answering into a queue that never moves. Live symptom: the photo
+backup stalled after a few dozen photos, its slot granted and the grant never
+delivered (docs/MESHBAY_DESIGN.md §9.12).
+
+The fix is the invariant the code assumed: a chunk being sent again is in
+flight, so it is not acknowledged. `test_sctp_fix.py` replays the sequence
+against aiortc itself.
+"""
+
+from aiortc.rtcsctptransport import RTCSctpTransport
+
+
+def _flight_size_increase(self, chunk) -> None:
+ chunk._acked = False
+ self._flight_size += chunk._book_size
+
+
+RTCSctpTransport._flight_size_increase = _flight_size_increase
diff --git a/packages/meshbay-node/tests/test_sctp_fix.py b/packages/meshbay-node/tests/test_sctp_fix.py
new file mode 100644
index 0000000..0b2536a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_sctp_fix.py
@@ -0,0 +1,59 @@
+"""aiortc's SCTP sender must not leak bytes in flight (see sctp_fix.py).
+
+Replayed against aiortc's own RTCSctpTransport: three chunks sent, the first
+lost and the other two acknowledged in a gap block, T3 expires and they are
+sent again, then a cumulative ack covers all three. Nothing is outstanding, so
+nothing may be counted in flight; before the fix one chunk stayed counted, and
+enough of those stop the node from sending anything on that connection.
+"""
+
+import asyncio
+
+from aiortc.rtcsctptransport import RTCSctpTransport, SackChunk
+
+import meshbay_node.transport.webrtc.core # noqa: F401 (applies the fix)
+
+
+class _Dtls:
+ state = "connected"
+
+ def _register_data_receiver(self, *a):
+ pass
+
+
+def _sack(cumulative_tsn: int, gaps: list) -> SackChunk:
+ sack = SackChunk()
+ sack.cumulative_tsn = cumulative_tsn % 2**32
+ sack.gaps = gaps
+ sack.duplicates = []
+ sack.advertised_rwnd = 1 << 20
+ return sack
+
+
+async def _replay() -> RTCSctpTransport:
+ s = RTCSctpTransport(_Dtls())
+ s._association_state = s.State.ESTABLISHED
+
+ async def sent(*_a, **_k):
+ pass
+
+ s._send_chunk = sent
+ for name in ("_t3_start", "_t3_restart", "_t3_cancel"):
+ setattr(s, name, lambda: None)
+ s._cwnd = 100_000
+
+ for _ in range(3):
+ await s._send(1, 51, b"x" * 1000)
+ tsns = [c.tsn for c in s._sent_queue]
+
+ await s._receive_sack_chunk(_sack(tsns[0] - 1, [(1, 2)]))
+ s._t3_expired()
+ await s._transmit()
+ await s._receive_sack_chunk(_sack(tsns[-1], []))
+ return s
+
+
+def test_nothing_outstanding_means_nothing_in_flight():
+ s = asyncio.run(_replay())
+ assert not s._sent_queue
+ assert s._flight_size == 0