September 25, 2026
8 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Android's Wi-Fi Direct manager and iOS's Multipeer manager fill the engine's peer-stream slot, and until now both dropped every inbound frame and announced no peer. Neither side could say who a peer was. Both now follow the stream chapter. Each side sends its identity assertion first and checks the peer's with verifyIdentityAssertion. It hands the core only the address that check proved.
This is PR5 of track T1 (the peer stream), after #455 (codec and verifier), #456 (the chapter), #457 (registration) and #458 (the Python manager).
What changed
The per-peer rules left the managers, so CI can run them. Both managers need hardware no runner has.
| Piece | Platform | What it owns | Tested by |
|---|---|---|---|
PeerStreamFraming | both | prefix, inclusive 1 MiB ceiling, 96-byte floor, position rule, PeerStreamLinks (one announced stream per address, newer supersedes) | the chapter's vector file, replayed on each platform |
PeerStreamSockets | Android | a socket from first byte to end: one preamble deadline, a per-stream ordered writer, a progress-based write deadline, a stall-only queue bound, and a per-stream lock so no body follows the loss report | 17 tests on real loopback sockets |
PeerStreamSession | iOS | the same rules for a message-oriented carrier, generic over the peer handle | 17 tests with a fake session and a manual clock |
Faults fixed on the way:
- Android refused a body of exactly 1 MiB. After any refused length it read the skipped body as the next prefix.
- Android could run two writes to one socket at once, which interleaves frames.
- Android sent a message to every socket in the group when its recipient had no socket.
- A group client opened another socket to its owner on every group change. It now keeps one and reconnects it on a doubling delay while the group lasts.
- iOS handled connects on main and messages on another queue, so a peer's first message could overtake its connect. Every per-peer event now runs on one serial queue.
- iOS advertised Multipeer type
offline-protowith the app's profile in the discovery info. It now advertisesofflineprotocolwithtxtversandaddr, andaddris checked against the preamble for peers we invite.
The BLE centrals use the strict verifier (second commit). Both split the assertion locally and checked it with the permissive verifySignature, which accepts some forgeries the strict check refuses. They now make one verifyIdentityAssertion call. The unused SignedIdentityData decoders are deleted.
Source guards. The guard that forbade the three wifi_direct_* calls is inverted: each call must exist once, with the proved address. A new guard keeps the registries and engines from naming the core. The iOS stateLock count drops from 12 to 6.
Breaking for iOS apps that enable wifiDirect
An app must list both _offlineprotocol._tcp and _offlineprotocol._udp under NSBonjourServices, or iOS blocks discovery with no error. An iPhone on this release does not see one on an earlier release over Multipeer. Neither carried traffic before, so no working path is lost. The example app's Info.plist and the integration guides are updated.
Verification
| Gate | Result |
|---|---|
cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings | clean |
cargo test --workspace --lib | all pass (uniffi 150, including the 45 React Native guards) |
transport integration tests incl. stream_framing_vectors (reads the edited chapter) | pass |
tools/spec-vectors/generate.py --check | 9/9 match |
RUSTDOCFLAGS="-D warnings" cargo doc -p offline-protocol-uniffi | clean |
| Android unit tests (CI harness, clean copy) | 546 pass, 37 new |
iOS swift test | 401 pass, 38 new |
| Both iOS CI typecheck steps (enumerated list, and every source with React headers) | clean; the enumerated one negative-controlled |
Mutation checks, each restored afterwards:
- Android: 8 mutations, all caught. They covered the exclusive ceiling, no supersede close, a double announcement, no preamble deadline, an unconditional queue bound, no own-address check, no stream limit, and loss reported for a superseded stream.
- iOS: 7 mutations. Six were caught at first. The survivor was a redundant late-message guard, which I removed; the refused mark it duplicated is now the tested mechanism.
- Rust guards: 6 negative controls, all caught.
Not verified: the device run (C9)
No device run has been done. The plan marks this PR device-tested, and I had no hardware. Before merge, someone should run:
1. Android pair. Enable wifiDirect on two phones and form a group from system Settings, Wi-Fi Direct. Check that neighbor_discovered fires on both with off1… addresses. Send a DM each way and confirm hop_count == 0 over wifiDirect. Then disconnect the group and check for exactly one neighbor_lost on each side. Finally have the client leave that group and join one owned by a third phone, and confirm the client dials the new owner and announces it.
2. iPhone pair. Use an app whose Info.plist lists both Bonjour entries. Run the same checks, then kill one app and relaunch it to confirm the relaunched peer supersedes without a loss event. Repeat with the transport stopped and started in the same process, without a relaunch.
3. iPhone refusal. Make one iPhone refuse the other on purpose (an addr in the discovery info that the preamble does not prove is the easiest way). Confirm the refused side gets .notConnected within the preamble deadline. Every refusal and every supersede ends a peer through cancelConnectPeer, which Apple documents for connection attempts. If it does not disconnect a connected peer, the refused peer keeps its budget slot and the manager needs a fallback.
4. BLE regression. Pair an Android phone and an iPhone over Bluetooth LE, and pair with a Python peripheral, to confirm the strict verifier accepts real assertions.
Review fixes
- iOS sent its own preamble only on
.connected. When the peer's preamble arrived first, the peer was announced before ours went out, and the core sends at once on an announcement (outbox flush, key package). A message could reach the peer ahead of our assertion and get the stream refused. The preamble now goes out before the announcement, pinned in the session tests and the Rust guard. - iOS
.notConnectedis now scoped to the current session, like the other two callbacks. A late disconnect from a replaced session could end the sameMCPeerID's live link. - Android cancelled write deadlines are removed from the scheduler at once, not held for thirty seconds.
- K8 states the Android half-open limit: Java cannot set the keepalive interval.
Second round:
- An Android group client redials the owner the group has when the redial fires. On a group switch the new owner's first dial could lose to the old socket still closing, and the old socket's redial refused to dial a different owner, so the client had no stream for the whole new group. The decision is a pure function with a unit test, and the mutation back to the old rule is caught.
- iOS mints its
MCPeerIDin everystart(). One id for the manager's lifetime let a clean stop and start reconnect under a handle whose state said our preamble was already sent. - The one-link-per-address registry answers a second announcement as a no-op on both platforms. On iOS it was a
precondition, which would abort the host app. - Android reserves stream slots with one atomic increment, so sockets accepted together cannot overshoot the limit.
Local gates after the second round: Android 552 pass, iOS swift test 403 pass, the enumerated iOS typecheck clean, uniffi React Native guards 45 pass (the two new manager pins negative-controlled), cargo clippy --workspace -D warnings, cargo test --workspace --lib and cargo fmt --check clean.
Local gates after the fixes: iOS swift test 402 pass, Android 546 pass, uniffi React Native guards 45 pass, cargo fmt --check clean. Each iOS fix was reverted once and caught.
Residuals, not in this PR
wifi_direct_status_changed(true)still emitsTransportSwitched{to: WiFiDirect}when the layer comes up, while the slot staysConnectinguntil a peer is proved. This is observability only; emitting it on the first proved link is an engine change.- The slot's
sequence<u8>costs about 1.4 s of loop time per inbound MiB in Python, and both mobile bridges box every byte. Switching to UDLbytesneeds all bindings regenerated (C1), so it stays a separate PR. - The Android manager does not form a group itself; it joins one the system formed.
- iOS
MCPeerID.displayNameis still the app profile. It no longer reaches the core, but it is visible to nearby devices.
v0.27.0 shipped on every channel, and npm has it with provenance. The release run (36062141316) is still red, for two reasons:
1. Attempt 1. npm publish returned success at 21:47:53Z, but the registry had *staged* the 157 MB tarball. The version only became readable at 21:55:35Z. The verify step polled dist.attestations.url for 50 seconds. That command prints nothing when the version does not exist yet, so the step reported "published without provenance" for a version that had provenance.
2. Attempt 2, a re-run of failed jobs, published again and got this:
npm error 409 Conflict - Cannot publish over previously staged version "0.27.0".
Changes
- The verify step asks two separate questions. First it checks whether the version is readable, polling every 30 seconds for up to 20 minutes, with a 30-minute step timeout. Then it checks whether the version carries attestations. Each failure has its own error text saying what to check.
- The publish step is safe to re-run. It skips a version that is already on the registry. It treats the E409 "previously staged" and E403 "previously published" refusals as a handover to the verify step. Any other npm error still fails the step.
prepare-npm.shno longer printslipo: command not foundon the Linux release runner. The architecture line is informational, so the script skips it whenlipois missing.package.jsonnow writesrepository.urlthe way npm normalises it. That removes the "npm auto-corrected some errors" warning from every publish.
Testing
I extracted both step scripts from the workflow YAML and ran them locally. bash -n and shellcheck are clean on both scripts and on prepare-npm.sh.
| Scenario | Result |
|---|---|
| verify, 0.27.0 (live, attested) | readable on poll 1, provenance found, exit 0 |
| verify, 0.99.99 (never published) | "not visible" error after the poll budget, exit 1 |
| publish, 0.27.0 already live (real registry) | skipped with a notice, exit 0 |
| publish, fake npm: E409 staged | notice, exit 0 |
| publish, fake npm: E403 previously published | notice, exit 0 |
| publish, fake npm: ENEEDAUTH | exit 1 |
| publish, fake npm: success | exit 0 |
The trusted-publishing path itself is untouched: the workflow filename, the Node version, the OIDC rehearsal, and the rule of no --provenance flag. The next real tag is the first end-to-end run of this change.
Lockfile-only patch bumps, combined into one PR so the third-party notices are regenerated once:
- serde 1.0.228 -> 1.0.229 (#413)
- thiserror 2.0.17 -> 2.0.20 (#415)
- chrono 0.4.42 -> 0.4.45 (#250)
Each Dependabot PR failed only Third-Party Notices Drift, because none of them regenerated the notices files. This PR includes the regenerated THIRD-PARTY-NOTICES.md files (cargo-about 0.9.1, the version the script pins).
The mesh_forwarding failures on #415 and #433 are the known everyone_hearing_everyone_does_not_multiply_the_traffic flake. They are unrelated to these bumps.
Verified locally:
cargo clippy --workspace -- -D warnings- no_std clippy on thumbv8m for core, sealed and leaf (serde and thiserror are used there)
cargo test --workspace: 3024 passed, 0 failed
Dependabot closes #413, #415 and #250 on its own once this lands.
Follow-up to #461. The React Native integration guide did not mention the per-send appId.
What changes in docs/react-native-integration.md
sendMessageandsendMedialistappId?. The guide now says it is not sealed like the rich params: it is cleartext, unsigned routing metadata that reaches every recipient.- It names where the id arrives:
app_idonmessage_received,file_receivedandmedia_resend_required. - The over-the-air caveat said an older native binary fails a rich call. That is wrong for
appId. The 0.26.0 bridges read named keys from the options map and ignoreapp_id, so the call succeeds and the message goes out under the configured id with no error. The caveat now says so and tells apps to gateappIdon the native binary. - Em dashes on the three touched lines are replaced.
Verified against bindings/react-native/src/index.ts (both hasRichOptions predicates include appId), types.ts (the three event fields), and the v0.26.0 Swift and Kotlin sendMessageRich parsers.
Docs only. No CHANGELOG entry: the [Unreleased] entry from #461 already covers the feature.
What this does
One engine instance stamped one app id, the configured one, on every frame it originated, and the receiver never reported the id of a frame it delivered. An instance that serves several local applications behind one identity could neither send as a named application nor tell its clients which application a message was for. This is the engine half of that; the multiplexing itself belongs to the local API server.
- Send.
SendMessageOptionsandMediaSendOptionstake an optionalapp_id(React NativeappIdonsendMessageandsendMedia), stamped on that message or every chunk of that transfer. Acknowledgements and control frames keep the configured id. - Receive.
message_receivedcarriesapp_idon the live and delayed-decrypt paths,file_receivedcarries it from chunk 0, andmedia_resend_requirednames the per-send id of an interrupted transfer. - Fix. The iOS, Android and Python bridges stamped
"offline-messenger"on every relay answer, relay group frame and legacy DM they rebuilt for the core, whatever the app had configured. They now stamp the configured id. - Docs. Threat model R17: the id is cleartext and unsigned, so it is routing metadata, never an authorization input.
No wire change: app_id is the existing frozen fourth envelope field, already validated on both codecs and carried unchanged by relays, retries and reseals.
Where a frame is rebuilt, and why each record carries the id
The id is validated at the boundary, before the send can park or tick the Lamport clock, and then kept on every record a frame is later rebuilt from:
| Record | Without the field |
|---|---|
| Pending message (persisted) | the flush re-stamped the configured id |
| Outbound media transfer | the pump built batches after the first under the configured id |
| Media transfer descriptor (persisted) | a restart could not say which application must resend |
| Inbound chunk-0 record | file_received could not report it |
Both persisted fields are serde-defaulted, so records written before this restore as the configured id, which is what they were queued with. Neither struct denies unknown fields, so a rollback reads the new records.
Decisions
group_message_receivedis left out. On the relay broadcast path the outer frame is one the *receiving* bridge synthesizes, so the field would carry the receiver's own id exactly where most groups deliver. Making it honest needs the relay JSON and three bridge whitelists to carry the sender's id. Group routing in the server goes by group ownership instead.- No sealed copy yet. R17 records what would close the rewrite (a sealed copy, as
content_typehas); it is left for the local API work that first depends on it. forward_messagekeeps the configured id.file_received.app_idwas added beyond the original plan: without it a per-send media id reached the wire and stopped there.
Breaking, narrowly
Python InternetManager now requires app_id= (keyword-only, no default), because the default was the fixed literal. ProtocolManager already passes it. The Swift and Kotlin builder and InternetManager signature changes are internal to the bridges.
Tests
Every new test was negative-controlled by mutating the one line it pins.
- Engine (9 tests, 10 mutations, all caught): stamp and fallback, invalid ids fail with the clock and queue untouched, park then flush, park then restart then flush, legacy record, every chunk of a pumped transfer plus
file_received, resend after restart, live and delayedmessage_received, and acknowledgements on the configured id. - FFI: an invalid id maps to
InvalidArgumentand a valid one reaches the outgoing frame. - Guards:
react_native_rich_sends_carry_the_app_id_end_to_end(C5, now thirteen sets) pins the spelling at every hand-written RN layer.bridges_stamp_the_configured_app_id_on_synthesized_framesrefuses the literal and any defaulted builder parameter. - JS:
js-ci-harness/rich-send-app-id.test.jspins that an appId-only call takes the rich native method.
Local gates: fmt, clippy (workspace and --no-default-features), cargo test --workspace --lib and --doc, rustdoc -D warnings, bindings regenerated twice with no drift, tsc, npm run test:js, Swift 363, the CI iOS typecheck of both steps (negative-controlled), Android 509 in a clean copy, Python 375.
Not covered: no device run. The bridge changes are a constructor argument and one dictionary read each, and they are typechecked, unit-tested and pinned by source guards.
Cuts 0.27.0, a minor. The public surface grows by six UDL operations (verify_identity_assertion, identity_assertion, remove_doc, remove_space, set_interest, fetch_attachment_from). Two narrow removals come with it: WIFI_DIRECT_MAX_PAYLOAD_SIZE from the transport crate, and identity_json from Python's BlePeripheral.
Version files
Cargo.toml: the workspace version and the eleven internal dependencies.Cargo.lockis refreshed.crates/offline-protocol-sealed/Cargo.tomlandcrates/offline-protocol-leaf/Cargo.toml: the four locally declared internal dependencies.tools/embedded-footprint/Cargo.lockandtools/mls-interop/Cargo.lock.bindings/python/pyproject.toml, and the React Nativepackage.jsonplus its lockfile.- The three
THIRD-PARTY-NOTICES.mdfiles, regenerated.
All twelve publishable crate names are reserved on crates.io at 0.26.0, so there is nothing to reserve before tagging.
Prose
- CHANGELOG.md:
[Unreleased]becomes[0.27.0] — 2026-09-25, with a release header. The stream framing chapter entry moves under a new Documentation section. There is one heading each for Added, Changed, Fixed and Documentation, and no PR references. - Archive: 0.26.0 moves to
docs/changelog/0.26.md, with a row added to both archive tables. The move is lossless: re-extracting the section from HEAD and applying the link rewrite differs on exactly the three link lines. - docs/UPGRADING.md: the guide now targets the
v0.27.xline, and sections 22 to 24 are labelled with the release. A new section 25 covers the changes that compile fine and still behave differently: - relay group answers now reach iOS and Android devices with encryption on;
forceTransportandgetTransportMetricsnow map every transport name;- a send refused as not reachable now fails with
peer_not_reachable; - the
wifi_directslot is registered; - failed-message telemetry rows are capped per reason;
- the two removals.
- Stale count: the intro said four releases could break a build when there were already five. It now says six.
- SECURITY.md: 0.27.x is the supported line.
Changelog audit
Every commit since v0.26.0 has an entry except two, both correctly omitted:
- #446 changed only a test.
- #459 fixed a break that never shipped.
All changelog lines added in the range sit inside the release section.
Verification
Run locally on this branch:
cargo fmt --all -- --checkcargo clippy --workspace --locked -- -D warningscargo test --workspace --libRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps- the three no_std targets
--lockedmetadata for both tool workspacesscripts/check-license-consistency.shnpx tsc --noEmit- a markdown link and anchor check across every tracked
.mdfile
gitleaks found nothing in the 16 commits since v0.26.0.
Before tagging
Three changes in this range have test coverage only, with no device run:
- relay group traffic on phones;
- the multi-app BLE app tag;
- the claim that the peer-stream slot is inert on phones.
A two-phone group-messaging smoke test through the relay would retire the largest of these. Pushing v0.27.0-rc.1 first runs every release gate and publishes npm under next without touching crates.io.
Every iOS app build on main fails, and CI is green. PR #457 routed getTransportMetrics and forceTransport through one mapper, transportType(from:), but both methods take a parameter also named transportType. Inside them the bare name is the String, so swiftc refuses both call sites:
OfflineProtocolModule.swift:3518:24: error: cannot call value of non-function type 'String'
OfflineProtocolModule.swift:3551:28: error: cannot call value of non-function type 'String'
The podspec compiles this file, so every consumer is affected. v0.26.0 is not, because it still had the per-method switches. This must land before the next release is cut.
Found while verifying #447 from the code side ahead of that release.
Changes
1. The compile fix. Both calls are qualified with self.. The Rust guard react_native_transport_name_mappers_know_every_transport still matches.
2. CI now compiles OfflineProtocolModule.swift. The macOS iOS job installs the pinned react-native devDependency without running scripts and runs the symlink-farm typecheck from ios/BRIDGE_MAINTENANCE.md over every hand-written source, found by glob. The step fails on the unfixed file and passes on this branch. ProtocolErrorBridge.swift gains compile coverage from the glob as well. docs/bridges/swift.md and BRIDGE_MAINTENANCE.md are updated to match.
3. iOS BLE selection no longer stalls on a failed instance. On a multi-instance link, an instance whose characteristic discovery errored was never recorded, so the choice waited the full three-second deadline. It now counts as unable to handshake. Single-instance links have no probe and are unchanged.
4. A Robolectric test that drives the real Android CentralGattClient through a two-instance link. CI never exercised this path before. It has 11 cases: serialised tag reads, all three choice rules, the tag-read watchdog, a late answer not credited to the next instance, all three MTU exits, a fresh choice per connection, and no tag read on a single-instance link. It uses a small shadow because the stock BluetoothGatt shadow has no readCharacteristic.
No changelog entry. The compile break and the #447 residual are both in unreleased code, and the rest is CI and tests.
Verification
- iOS: the new CI step's exact script passes locally on 44 sources, with RN 0.86.0 headers and the iOS 26.5 SDK. It fails with both errors when the fix is reverted.
- Android: the module suite passes 508/508 in the CI-equivalent harness, 11 of them new. Each of these four mutations of the client fails at least one new case: dropping the MTU-callback exit, dropping the watchdog exit, removing the late-answer instance check, and making
handshakeServiceignore the binding. - Swift package: 362/362.
- Rust: 43 React Native source guards pass.
- The three app-tag vectors reproduce independently with
shasum.
What
The host implementation of docs/spec/stream-framing.md: PeerStreamManager in the Python binding, behind the wifi_direct slot #457 registers. A TCP listener plus outbound connections to configured peers (host:port, or off1…@host:port to require that the derived address equal the claim exactly) and to hosts found over DNS-SD, with the preamble exchange the chapter specifies. Each side sends its identity assertion as the first frame without waiting for the other's, verifies what it receives through verify_identity_assertion, and announces the peer under the derived address only. A stream whose preamble fails, arrives late, or names another address is closed and was never announced, so its close reports nothing. Zero and over-ceiling prefixes close the stream before a byte of the body; an under-floor preamble is refused on the prefix; a body of exactly 1 MiB is delivered.
ProtocolManager builds it when wifi_direct_enabled is set, installs the slot's callback only then (the nostr/reticulum gating, so an app driving the slot itself is not silently shadowed), and stops it with the rest. The application starts it after ProtocolManager.start(); start() refuses without an identity to prove, since every conforming peer would close such a stream at the first frame.
Two local policies, chosen so two hosts agree without talking
The chapter leaves which stream to keep to the implementation and fixes only the count. Here:
- When a second stream proves an address already announced, the stream opened by the lower address is kept. Two hosts that list each other open toward each other at once, and without a shared rule each keeps the stream the other discards, closes the other's, and reconnects forever. A connector never opens toward a peer that is already announced.
- Between two streams of the winning kind, the newer supersedes the older, so the lower address reconnecting past its own half-open stream is not blocked. The cost, that a copied preamble can force the real peer to reconnect, is bounded to exactly that (R16). The higher address gets no such help, so every stream carries TCP keepalive: a dead peer's stream ends in about half a minute, and the reconnect wins from then on.
Either way the core sees one announcement and, later, one loss per address; the superseded or refused stream closes without a report.
DNS-SD
_offlineprotocol._tcp, txtvers=1 first, addr=<off1…>, instance name a digest (the address lives in the TXT entry, once). Through the optional lan extra (pip install 'offline-protocol-sdk[lan]'); zeroconf is imported lazily, so the base install's dependency set is unchanged and carries no LGPL package. The addr in a record is the claim the preamble is checked against, never a name to announce, and a record naming our own address is ignored.
Bounds
The listener binds every interface by default. One deadline covers the whole preamble frame, prefix and body. One remote host holds at most eight inbound streams, out of sixty-four. Any attempt that ends without an announced stream climbs a doubling reconnect ladder, and a refused preamble backs off to ten minutes.
Tests
test_peer_stream_manager.py, 57 tests on real loopback sockets with a mocked protocol: the preamble in every outcome (verified, unverifiable, under the floor on the prefix alone, a message in its position, silent past the deadline, our own address), the framing bounds including a 1 MiB body, outbound drain onto the proved stream and nowhere else, the tie-break in all three shapes including two managers that list each other, static-peer connect and reconnect, the exact-claim refusal, PeerEntry parsing, the DNS-SD record helpers, and the chapter's vectors replayed through the real verifier. Bounds are asserted as literals (4, 1_048_576, 96) for the C5 reason. test_protocol_manager.py pins the wiring and the callback gating.
The two-host run
Two ProtocolManagers in one process with real identities, in-memory stores and the real verifier, B listening, A configured with off1…(B)@127.0.0.1:<port>: both announce the other's derived address, a message forced onto the slot arrives at B as message_received from A's address at hop_count 0 via wifiDirect, and stopping A's manager produces exactly one neighbor_lost at B.
DNS-SD coverage
zeroconf is in the dev extra, so CI runs a live advertise-and-browse round trip on one host. That test is the only check that a published record resolves. A run across two machines on a real LAN is still the remaining check (C9).
Review fixes
The first review found three blockers, and each fix has a test that fails when the fix is reverted:
- The DNS-SD record carried no host name and no addresses, so no browser could resolve it.
- The preamble deadline covered only the four-byte prefix.
- The higher address could never reconnect past the lower side's stale stream.
The same commit also fixes these smaller findings:
- A superseded stream no longer hands a body to the core without a sender.
- A connector no longer dies on a connect error that is not an
OSError. stop()now waits for its connectors to finish.- Refusals are remembered only for outbound endpoints.
- A removed DNS-SD record now retires its connector.
- Each record gets one connector that tries its addresses in turn, with scoped link-local addresses.
The second review found that one peer could hold every other peer's outbound traffic, and each fix again has a test that fails without it:
- The drain pulled the core's one queue and awaited each write, so a peer that stopped reading stalled delivery to all peers. Each stream now has its own queue and its own writer, and a peer that takes no byte for
write_timeout(30 s) has its stream aborted and reported lost. - A discarded stream with bytes still buffered is aborted, not closed.
close()waits for a flush that never comes, sostop()used to hang for its full two seconds, and a restart after it never wrote again. - A body no receiver would accept is dropped without closing the stream.
- An inbound connect served after
stop()swept the streams is refused. - A peer entry that points at this host now climbs to the refusal ceiling.
The third review found that both of those bounds fired on healthy peers, and each fix has a test that fails without it:
- The 4 MiB queue bound was checked before the writer had run, so a burst handed over in one tick lost frames to a peer that was reading. It now applies only while the writer is blocked on the peer.
- The write deadline was per frame, a throughput floor of about 35 KiB/s for a 1 MiB frame, so a slow link below it flapped forever. It now trips only when a whole
write_timeoutpasses with no progress.
The changelog's "about half a minute" now says it holds for idle streams, and P9 records the write-path shape a mobile port owes.
Whole Python suite against a library built from this branch: 375 passed.
Docs
P9 in docs/bridges/python.md, the Python row of the bridge obligations, the README transport table row, the transport-architecture host line, and the changelog entry.