diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 11:06:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 11:06:53 +0200 |
| commit | 0ead6a321fbe3cf97e22f6fee348f68b09bd53a8 (patch) | |
| tree | ada17e460b9c8ce3ffc46211845f57f8209148de /packages | |
| parent | c642acaa812ad347d9dbf604bf391bbed970d987 (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages')
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 |