Skip to content

feat(bindings): the iOS peer stream rides Network framework instead of Multipeer - #498

Merged
bahdotsh merged 16 commits into
mainfrom
feat/ios-network-framework-peer-stream
Oct 2, 2026
Merged

bahdotsh merged 16 commits into
mainfrom
feat/ios-network-framework-peer-stream

Conversation

@mizanisoffline

@mizanisoffline mizanisoffline commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Summary

Moves the iOS peer-stream slot (wifi_direct) from MultipeerConnectivity to Network framework: TCP over NWListener, NWBrowser and NWConnection, with includePeerToPeer. The decisions are recorded in ADR 0027.

Why. Multipeer was the one carrier that did not implement docs/spec/stream-framing.md as written. It is message-oriented, caps a session at 8 peers, only talks to other Multipeer apps, and publishes _offlineprotocol._tcp with its own protocol behind it. A Python host on the same LAN finds that record and dials a socket that does not speak the chapter. Network framework gives a plain byte stream, still reaches other iPhones over AWDL with no access point, and lets an iPhone and a Python PeerStreamManager on one LAN find and talk to each other. It is also the base Wi-Fi Aware (iOS 26) would build on, which stays a follow-up.

What changed:

  • The manager. WifiDirectManager.swift is rewritten. Every Network callback runs on one serial queue, writes are bounded as on Android, keepalive matches the Python host, the stream budget is 16, and a local-network denial is reported as a diagnostic.
  • Duplicate streams. Both ends now dial, so of two streams for one address, PeerStreamLinks keeps the one the lower address opened, and the newer of two such. This is the Python manager's rule. A Rust guard pins the Swift and Python copies together, because the alternative ("lower dials, newer wins") reconnects forever against a Python host. The higher address waits 5 s before dialing, so iPhone to iPhone usually opens a single stream.
  • New Foundation-only pieces, unit-tested in the SwiftPM harness:
    • PeerStreamReader cuts frames off the stream and refuses an out-of-bounds prefix before buffering any body.
    • PeerStreamDialPolicy decides when to dial and redial. The backoff only grows on a redial that is actually scheduled.
  • Service records. The browser ignores sid service-instance records, as main's DNS-SD mapping requires.
  • Packaging. The podspec links Network instead of MultipeerConnectivity.
  • Docs. The spec, threat model (R16, hop encryption), S8, the guides and CHANGELOG are updated.
  • Test tool. tools/peer-stream-host/peer_stream_host.py lets one iPhone be tested against a Mac.

Breaking for apps.

  • An iPhone on this release does not see one on 0.27 or earlier over this slot.
  • NSBonjourServices needs only _offlineprotocol._tcp.
  • The hop is now plain TCP, like Android and Python. Payloads stay MLS-sealed.

Builds on #497 (merged). That PR fixed a pre-existing core bug: a key package lost on a superseded stream left a pair with no session. This branch makes simultaneous dials, and so that bug, common for iOS to Python.

Type of change

  • feat — new feature
  • fix — bug fix
  • docs — documentation only
  • test — adding or correcting tests

Testing

Automated:

  • cargo fmt, clippy -D warnings, cargo test --workspace (doctests included) and cargo doc -D warnings all pass.
  • SwiftPM harness: 448 tests, including the session, links, reader and dial-policy suites.
  • CI iOS bridge typecheck: 0 errors. The manager also typechecks against the pod's iOS 13 target.
  • Rust source guards: re-pinned to the new manager, plus the Swift/Python tie-break guard and the sid guard.
  • Review: a separate review pass scored 9.0/10 and approved, after two fixes (reader copies, dial ladder).

On a device. C9: CI does not exercise the radio. An iPhone 16 ran the example app against the Python host on a Mac on one Wi-Fi network, twice: once before #497 merged, then again on the synced branch at 6af7c33 with #497 in the build. The table is the second run. Every check ran with the Mac's address below the iPhone's and above it, so both sides of the tie-break were covered.

Check Result
One stream, messages both ways, 5 min with no flap Pass (one stream, no refusals, no losses)
Both ends dial at once, one stream supersedes the other Pass (the session formed in the same second; before #497 this left no session about 1 connect in 5)
500-message burst; 2 MB file Mac to iPhone; photo iPhone to Mac Pass (500/500 delivered and acknowledged; file present; photo received at its declared size)
Mac host stops and restarts Pass (one loss, reconnect in about 3 s)
App backgrounded, then foregrounded Pass (one loss, reconnect 5 to 6 s after foreground, session intact)
Mac Wi-Fi off, then on, no cable Pass (both sides detect the silent loss once; recovers on its own, session intact)
Local network denied Pass (the denial diagnostic fires at transport start; the Mac never sees the iPhone)

The first run also pulled the USB link mid-stream, a dead path with no close: detected in about 20 to 30 s, and the stream moved to Wi-Fi.

Not run on devices. Two iPhones over AWDL with no access point, the case Multipeer covered: only one iPhone was available. ADR 0027 records it as owed before a release claims it.

Found and not fixed here:

  • A split session, seen once (INVALID_CIPHERTEXT). Not covered by fix(protocol): push a key package again when it produced no session #497 either. Needs its own investigation.
  • Slow reconnect after foreground. It takes about 6 s, mostly the fixed 5 s listener/browser rebuild delay after suspension. It could rebuild on foreground instead.
  • The Python host's loss label. It reports a lost, already-proven stream as "no preamble within the deadline", which is the wrong reason.
  • Building with Xcode 27. The example does not build as-is: the podspec's iOS 13.0 floor breaks the privacy bundle, and the fmt pod fails with the new clang. This predates the branch.

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes
  • cargo-deny is satisfied (no dependency changes)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated
  • No new unsafe
  • UDL unchanged; no binding regeneration needed

@mizanisoffline
mizanisoffline marked this pull request as draft October 1, 2026 11:37
… stream frame reader

PeerStreamLinks now keeps the stream the lower address opened, and among
two such the newer, the rule the Python PeerStreamManager already uses.
Both ends compute it alike, so when both sides of a pair dial (as they will
over Network framework, and as a Python host on a LAN always does) they keep
the same stream instead of closing each other's forever. PeerStreamSession
takes each handle's direction and closes a refused duplicate with no report.

PeerStreamReader cuts whole frames off a byte stream and refuses an
out-of-bounds prefix before buffering any body.

A new guard pins the Swift and Python tie-breaks together. The Multipeer
manager passes every stream as inbound as a stop-gap until the next commit
replaces it.
…f Multipeer

WifiDirectManager now fills the peer-stream slot with TCP streams over
NWListener, NWBrowser and NWConnection, with includePeerToPeer so two
iPhones still reach each other over AWDL with no access point. It
advertises and browses _offlineprotocol._tcp with txtvers=1 first and
addr, the record the Python PeerStreamManager publishes, so an iPhone and a
host on one LAN now find and talk to each other.

The lower address dials at once and the higher after five seconds;
PeerStreamLinks keeps the stream the lower address opened. Every stream is
cut into frames by PeerStreamReader and handed to PeerStreamSession, all on
one serial queue. Writes are bounded as on Android (4 MiB queued while
stalled, 30 s without completion ends the stream), keepalive matches the
Python manager, the stream budget is 16 instead of Multipeer's 7, and a
local-network denial is reported instead of failing silently.

Multipeer builds and these builds do not see each other. Apps need only
_offlineprotocol._tcp in NSBonjourServices. The podspec links Network in
place of MultipeerConnectivity, and the Swift source guards are re-pinned
to the new manager.
ADR 0027 records the move and the tie-break: of two streams for one
address, keep the one the lower address opened. The alternative, the lower
address dials and the newer stream wins, reconnects forever against a
Python host, which dials everything it discovers.

The stream chapter drops the Multipeer special cases and says why iOS and
Python share a rule while Android keeps the newer stream. The threat model
moves iOS into the lower-opened column of R16 and records that no
peer-stream hop is encrypted. The Swift bridge rule S8, the iOS guide, the
configuration and transport references, and CHANGELOG follow; the
unreleased Multipeer service-type entry is replaced, since it never shipped.
…t the dial ladder

PeerStreamReader re-copied the rest of its buffer after every frame, so a
64 KiB receive chunk of small frames cost its size squared on the queue
every Network callback shares. It now reads at an offset and compacts once
per chunk.

The dial and redial decisions move out of WifiDirectManager into
PeerStreamDialPolicy, which the SwiftPM harness tests. That fixes the
ladder climbing on a stream end whose redial was then declined because
another stream held the address: the higher address of a pair lost that
race on every first contact, and waited out an inflated delay the first
time it really had to reconnect.
… with one iPhone

The Mac plays the second device: it advertises and browses
_offlineprotocol._tcp, proves its address with the preamble, and logs every
connect, message, loss and stream decision with a timestamp. `identities`
makes one Mac identity sorting below the iPhone's address and one above, so
both sides of the ADR 0027 tie-break get exercised. `run` takes `send`,
`burst`, `file` and `metrics` commands, flags a reconnect loop as FLAP?, and
with --no-discover only advertises, so only the iPhone can open a stream.
…ecords

The stream chapter now says a browser looking for peers MUST ignore a record
carrying `sid`: it is a service instance under the DNS-SD mapping, naming the
same host and address as the peer record, so taking it as a peer opens a
second stream to one host per service. The Python browser landed that guard
on main; the Network-framework browser predates the rule. It now skips such
records, and the proved-addresses guard pins it.
@mizanisoffline
mizanisoffline force-pushed the feat/ios-network-framework-peer-stream branch from a3e90dc to f839d36 Compare October 1, 2026 15:11
…dress

The browser removed an advert by the address its record carried. Every
listener restart minted a new random instance name, so a peer back from the
background was advertised by its new record and its old one at once, and the
old one's removal dropped the address the new one still advertised. With no
advert, a scheduled dial was abandoned and an ended stream never redialed,
so the pair stayed apart with no error on either side.

The adverts are now rebuilt from every record the browser holds on each
change (a record the change added wins, then the one already in use), and
the instance name is a digest of the address, as on Python, so a restart
replaces its record instead of adding a second.

A dial with no path yet (.waiting) now gets ten seconds, the connect
timeout, before the redial ladder takes it over, since AWDL may still be
coming up. ADR 0027 records that two iPhones over AWDL have not yet been
run on devices.
The listener took any connection while fewer than sixteen streams were open,
the budget Android uses for a Wi-Fi Direct group. A LAN is not a closed group:
one machine re-opening silent sockets, each held for the ten second preamble
deadline, kept every slot, and since dials drew from the same budget the
device could neither be reached nor reach anyone over the slot.

Inbound streams are now bounded at four per remote host, the Python manager's
per-host bound scaled to this budget, and at twelve in all, so a full
listener always leaves room to dial.
A scheduled dial that found every stream slot taken was abandoned along with
the dials that were no longer due (advert gone, address held, transport
paused). Those three need nothing more, but nothing re-armed the fourth: an
ending stream redials only its own address and a browse change dials only a
fresh record, so a peer discovered while the budget was spent stayed undialed
until its record changed or the transport restarted, with no diagnostic.

A dial with no free slot now goes onto the redial ladder, so it is tried again
once slots free, at no more than one attempt a minute.
The TXT record encoder and the instance-name digest are the only way a peer
finds an iPhone, and both lived in the manager, which the SwiftPM harness
cannot compile, so nothing executed them: a wrong length byte or key would
have left the device invisible to every host with no error. They now sit
beside the framing, and a test pins the exact record bytes and the name
Python's service_instance_name gives the same address.

An entry longer than its length byte can say is now left out instead of
trapping the host app.
…its address

A record advertises an address its preamble must prove. When a dialed record
answered with a preamble for some other address, the session refused it, and
the manager put the address back on the redial ladder against the same
record, for as long as the record stayed in the browser's cache. The record
also stayed the advert for that address, so a real record for it was never
dialed. Any device on the LAN could hold one direction of a pair that way.

A dialed stream that delivered a whole frame and ended without proving its
address, while no other stream holds the address (so not a lost race), now
marks its record unprovable. The adverts leave it out, so another record for
the address is dialed, or none and the ladder stops, until the browser
reports the record again. Marks are bounded to the records the browser
still holds. The dial policy now hears of a proof once per stream rather than
on every received chunk.
…le waiting

A dial was bounded only while its connection reported `.waiting`, and the TCP
connect timeout covers the handshake, not resolving the record before it. A
dial toward a cached record whose host left without a goodbye could sit in
`.preparing` with no state change, holding a stream slot and its address's one
dial, so later adverts for the address dialed nothing.

Every dial now has ten seconds to become ready, whatever it waits on, as
Python's CONNECT_TIMEOUT and Android's connect timeout bound theirs, and the
redial ladder takes over after.
…ovable

Whether a dialed stream proved its address was inferred from whether it
held the address after a chunk was read. A dial that proved its address and
was then refused as the losing duplicate never held it, and neither did one
whose chunk ended in a reader refusal, so when such a stream ended with no
other stream holding the address, its record was marked unprovable and the
real peer was not dialed again until the browser reported it.

The session now says what each stream's preamble proved, whether or not the
stream went on to hold it, and the manager reads that right after each frame.
…ve keeps dials

The per-host bound keys on the remote address, so one machine with many
IPv6 addresses is many hosts to it, and the docs claimed more than that:
that one machine on the LAN could not fill the listener. What keeps this
device able to dial whatever the listener holds is the four slots inbound
streams cannot take. The constants, S8, ADR 0027 and the changelog now say
which bound does what, so the reserve is not given up on the strength of the
per-host one.
@mizanisoffline
mizanisoffline marked this pull request as ready for review October 1, 2026 18:29

@bahdotsh bahdotsh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@bahdotsh
bahdotsh merged commit 91bd018 into main Oct 2, 2026
46 of 47 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants