Repository navigation
feat(bindings): the iOS peer stream rides Network framework instead of Multipeer - #498
Merged
Merged
Conversation
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
force-pushed
the
feat/ios-network-framework-peer-stream
branch
from
October 1, 2026 15:11
a3e90dc to
f839d36
Compare
…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
marked this pull request as ready for review
October 1, 2026 18:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Moves the iOS peer-stream slot (
wifi_direct) from MultipeerConnectivity to Network framework: TCP overNWListener,NWBrowserandNWConnection, withincludePeerToPeer. The decisions are recorded in ADR 0027.Why. Multipeer was the one carrier that did not implement
docs/spec/stream-framing.mdas written. It is message-oriented, caps a session at 8 peers, only talks to other Multipeer apps, and publishes_offlineprotocol._tcpwith 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 PythonPeerStreamManageron 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:
WifiDirectManager.swiftis 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.PeerStreamLinkskeeps 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.PeerStreamReadercuts frames off the stream and refuses an out-of-bounds prefix before buffering any body.PeerStreamDialPolicydecides when to dial and redial. The backoff only grows on a redial that is actually scheduled.sidservice-instance records, as main's DNS-SD mapping requires.Networkinstead ofMultipeerConnectivity.tools/peer-stream-host/peer_stream_host.pylets one iPhone be tested against a Mac.Breaking for apps.
NSBonjourServicesneeds only_offlineprotocol._tcp.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 featurefix— bug fixdocs— documentation onlytest— adding or correcting testsTesting
Automated:
cargo fmt,clippy -D warnings,cargo test --workspace(doctests included) andcargo doc -D warningsall pass.sidguard.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.
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:
INVALID_CIPHERTEXT). Not covered by fix(protocol): push a key package again when it produced no session #497 either. Needs its own investigation.fmtpod fails with the new clang. This predates the branch.Checklist
cargo fmt --all -- --checkpassescargo clippy --workspace -- -D warningspassescargo test --workspacepassescargo-denyis satisfied (no dependency changes)CHANGELOG.mdupdatedunsafe