From 7084ae8ea6b521f453758df2f2728da2f33411fb Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Wed, 30 Sep 2026 22:33:33 +0400 Subject: [PATCH 01/16] feat(bindings): peer-stream links keep the lower-opened stream, and a 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. --- bindings/react-native/ios/Package.swift | 1 + .../react-native/ios/PeerStreamFraming.swift | 98 ++++++++++++++++--- .../react-native/ios/PeerStreamSession.swift | 44 ++++++--- .../react-native/ios/WifiDirectManager.swift | 5 +- .../ios/tests/PeerStreamFramingTests.swift | 81 ++++++++++++--- .../ios/tests/PeerStreamReaderTests.swift | 79 +++++++++++++++ .../ios/tests/PeerStreamSessionTests.swift | 86 +++++++++++++--- crates/offline-protocol-uniffi/src/lib.rs | 51 +++++++++- 8 files changed, 387 insertions(+), 58 deletions(-) create mode 100644 bindings/react-native/ios/tests/PeerStreamReaderTests.swift diff --git a/bindings/react-native/ios/Package.swift b/bindings/react-native/ios/Package.swift index b47a8482a..58641c2e2 100644 --- a/bindings/react-native/ios/Package.swift +++ b/bindings/react-native/ios/Package.swift @@ -133,6 +133,7 @@ let package = Package( "OutboundFragmentQueueTests.swift", "PeerIdentityBindingTests.swift", "PeerStreamFramingTests.swift", + "PeerStreamReaderTests.swift", "PeerStreamSessionTests.swift", "PeripheralRestorationAgeOutPolicyTests.swift", "PresenceWatchPolicyTests.swift", diff --git a/bindings/react-native/ios/PeerStreamFraming.swift b/bindings/react-native/ios/PeerStreamFraming.swift index 7a6bb53d7..39676a029 100644 --- a/bindings/react-native/ios/PeerStreamFraming.swift +++ b/bindings/react-native/ios/PeerStreamFraming.swift @@ -6,10 +6,10 @@ // length prefix, its bounds, the position rule that makes the first body on a // stream the peer's identity assertion, and one announced stream per address. // -// Foundation only, so the SwiftPM harness tests it without a Multipeer session +// Foundation only, so the SwiftPM harness tests it without a socket // (PeerStreamFramingTests replays the chapter's conformance vectors). -// WifiDirectManager owns the session and calls into this. Mirrors android's -// PeerStreamFraming.kt, keep in sync. +// WifiDirectManager owns the connections and calls into this. Mirrors +// android's PeerStreamFraming.kt, keep in sync. // import Foundation @@ -55,13 +55,13 @@ enum PeerStreamFraming { return nil } - /// The body of one whole frame delivered by a message-oriented carrier. + /// The body of one whole frame, as `PeerStreamReader` cuts it off the + /// stream. /// - /// A Multipeer session already delivers whole messages, and the chapter - /// still wraps each as one frame so that the ceiling is read off the same - /// four bytes on every carrier. The prefix must therefore account for - /// every byte after it: a message carrying more or less than its prefix - /// says is refused, since there is no stream to resynchronise on. + /// The prefix must account for every byte after it: a message carrying + /// more or less than its prefix says is refused. The reader already + /// refused a length over the ceiling before buffering its body; the + /// preamble floor is checked here, where the position is known. static func unframe(_ message: Data, preamble: Bool) -> Result { guard message.count >= prefixBytes else { return .failure(PeerStreamRefusal(reason: "shorter than a prefix")) @@ -80,6 +80,47 @@ enum PeerStreamFraming { } } +/// Cuts whole frames, prefix included, off a byte stream (the chapter's +/// "What a receiver owes", steps one and two). +/// +/// A prefix over the ceiling, or zero, is refused the moment its four bytes +/// are in, before a byte of the body is buffered: a reader that buffered first +/// would hand the peer a megabyte of this device's memory per stream for four +/// bytes. So the reader holds at most one frame, the ceiling plus the prefix. +/// After a refusal the stream is garbage and the reader returns nothing more; +/// the owner closes the stream. +/// +/// Not thread-safe. One stream's owner drives its instance from one queue. +final class PeerStreamReader { + private var buffer = Data() + private var refused = false + + /// Every frame `chunk` completes, in order, ending with a refusal if one + /// was met. + func append(_ chunk: Data) -> [Result] { + guard !refused else { return [] } + buffer.append(chunk) + var out: [Result] = [] + while buffer.count >= PeerStreamFraming.prefixBytes { + let start = buffer.startIndex + let length = UInt32(buffer[start]) << 24 | UInt32(buffer[start + 1]) << 16 + | UInt32(buffer[start + 2]) << 8 | UInt32(buffer[start + 3]) + // The floor is `unframe`'s to check, which knows the position. + if let refusal = PeerStreamFraming.refusal(forLength: length, preamble: false) { + refused = true + buffer = Data() + out.append(.failure(refusal)) + return out + } + let total = PeerStreamFraming.prefixBytes + Int(length) + guard buffer.count >= total else { break } + out.append(.success(Data(buffer.prefix(total)))) + buffer = Data(buffer.dropFirst(total)) + } + return out + } +} + /// The position rule for one stream: the first body is the peer's identity /// assertion, and every later body is a message from the address it proved. /// @@ -151,12 +192,17 @@ final class PeerStreamPreamble { /// preamble is enough to open a second stream for a live address, so the count /// has to be kept here, where the streams are. /// -/// Policy: the newer stream supersedes the older. On a phone the duplicate is -/// almost always the same peer reconnecting past a stream that went half-open -/// (a Multipeer peer that restarted and came back under a new `MCPeerID`), and -/// refusing the newer one would leave that peer unreachable until the stale -/// one timed out. The cost, recorded in R16, is that a replayer can choose when -/// a real stream ends; it cannot use the stream it gets. +/// Policy: the stream the lower address opened is kept, and between two of +/// those the newer supersedes the older. Both ends compute it alike, which is +/// the point: both ends of a pair may dial, and so does a Python host on the +/// same LAN, so without a shared rule each end would keep the stream the other +/// closes, and the pair would reconnect forever. It is the Python manager's +/// `_new_stream_wins`, and `ios_and_python_peer_streams_keep_the_same_stream` +/// pins the two copies together (ADR 0026). "Newer" among winners is what +/// lets the lower address reconnect past its own half-open stream; the higher +/// address's reconnect waits for keepalive to end the stale one. The cost, +/// recorded in R16, is that a replayer can end a real stream when its copy is +/// the winning kind; it cannot use the stream it gets. /// /// Thread-safe, and deliberately knows nothing of the protocol: the send path /// reads it from whichever thread the core calls `onMessagesAvailable` on, @@ -168,13 +214,29 @@ final class PeerStreamLinks { let firstForAddress: Bool /// The older stream for the same address, to close without a loss report. let superseded: Handle? + /// True when an older stream holds the address and wins: close this + /// one without a report, and leave the older untouched. + var refused = false + } + + /// Whether a new stream for `peer` takes the address over from the one + /// that holds it. `outbound` is whether this device opened the new + /// stream. Addresses compare by their UTF-8 bytes, which is the code point + /// order Python's `<` uses. With no address of our own there is nothing to + /// order by, and the announced stream stays. + static func newStreamWins(outbound: Bool, localAddress: String?, peer: String) -> Bool { + guard let local = localAddress else { return false } + let weOpen = local.utf8.lexicographicallyPrecedes(peer.utf8) + return outbound == weOpen } private let lock = NSLock() private var byAddress: [String: Handle] = [:] private var byHandle: [Handle: String] = [:] - func announce(_ handle: Handle, address: String) -> Announcement { + func announce( + _ handle: Handle, address: String, outbound: Bool, localAddress: String? + ) -> Announcement { lock.lock(); defer { lock.unlock() } if byHandle[handle] != nil { // A stream proves one address, once. A second announcement, for @@ -184,6 +246,10 @@ final class PeerStreamLinks { // trap here would take the app down for a bookkeeping mistake. return Announcement(firstForAddress: false, superseded: nil) } + if byAddress[address] != nil, + !Self.newStreamWins(outbound: outbound, localAddress: localAddress, peer: address) { + return Announcement(firstForAddress: false, superseded: nil, refused: true) + } let older = byAddress.updateValue(handle, forKey: address) byHandle[handle] = address if let older = older { byHandle.removeValue(forKey: older) } diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index 9df04ecfe..1e08d8711 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -2,15 +2,16 @@ // PeerStreamSession.swift // OfflineProtocol // -// docs/spec/stream-framing.md over a message-oriented carrier: what happens to -// one connected peer from its first message to its disconnect. +// docs/spec/stream-framing.md for one connected peer, from its first frame +// to its disconnect. // -// WifiDirectManager owns the Multipeer session, the advertiser and the -// browser, and forwards each per-peer event here from its one serial queue. -// Everything between is here, generic over the carrier's peer handle and -// behind `PeerStreamHost`, so that PeerStreamSessionTests drives it with -// string handles and a fake clock, without MultipeerConnectivity or the native -// library. Mirrors android's PeerStreamSockets.kt, keep in sync. +// WifiDirectManager owns the listener, the browser and the connections, cuts +// each stream into frames with `PeerStreamReader`, and forwards each per-peer +// event here from its one serial queue. Everything between is here, generic +// over the carrier's peer handle and behind `PeerStreamHost`, so that +// PeerStreamSessionTests drives it with string handles and a fake clock, +// without Network framework or the native library. Mirrors android's +// PeerStreamSockets.kt, keep in sync. // // The rules, each of which a test pins: // - Our preamble goes to a peer as soon as it connects, without waiting for @@ -19,8 +20,9 @@ // - The host is told only the address the preamble proved: `peerConnected` // once per address, each body attributed to it, and `peerDisconnected` once, // if and only if the peer still held the address when it ended. -// - One announced peer per address, newer superseding older, through -// `PeerStreamLinks`. A superseded peer is disconnected and reports nothing. +// - One announced peer per address, through `PeerStreamLinks`: the stream the +// lower address opened wins, and the newer of two such. A superseded or +// refused peer is disconnected and reports nothing. // - Every message is one frame whose prefix equals the rest of the message; // anything else disconnects the peer. // @@ -52,6 +54,7 @@ final class PeerStreamSession { private let send: (Data, Handle) throws -> Void private let disconnect: (Handle) -> Void private let schedule: (TimeInterval, @escaping () -> Void) -> Void + private let isOutbound: (Handle) -> Bool private let preambleTimeout: TimeInterval /// The peers that proved an address. Thread-safe on its own lock, and read @@ -78,18 +81,24 @@ final class PeerStreamSession { /// - disconnect: ends one peer's connection. Its own disconnect event /// may follow and finds nothing left to report. /// - schedule: runs a block after a delay on the owner's serial queue. + /// - isOutbound: whether this device opened the handle's stream, which + /// decides which of two streams for one address is kept. A property + /// of the handle rather than of an event, so no event order can get + /// it wrong. init( host: PeerStreamHost, preambleTimeout: TimeInterval = 10.0, send: @escaping (Data, Handle) throws -> Void, disconnect: @escaping (Handle) -> Void, - schedule: @escaping (TimeInterval, @escaping () -> Void) -> Void + schedule: @escaping (TimeInterval, @escaping () -> Void) -> Void, + isOutbound: @escaping (Handle) -> Bool ) { self.host = host self.preambleTimeout = preambleTimeout self.send = send self.disconnect = disconnect self.schedule = schedule + self.isOutbound = isOutbound } // MARK: - Thread-safe reads @@ -158,7 +167,18 @@ final class PeerStreamSession { // those go out on another queue; a message that reached the peer // ahead of our assertion would be refused and the peer lost. guard sendPreambleIfNeeded(handle, link) else { return } - let announcement = links.announce(handle, address: address) + let announcement = links.announce( + handle, address: address, outbound: isOutbound(handle), + localAddress: host.peerStreamLocalAddress()) + if announcement.refused { + // The losing kind of a second stream for a held address. The + // far end computes the same rule and keeps the same stream, + // so this closes with no report and the held one is untouched. + link.refused = true + disconnect(handle) + host.peerStreamDiagnostic("info", "Peer stream refused: another stream holds the address", ["address": address]) + return + } if let older = announcement.superseded { // Disconnected without a loss report: `links` already moved // the address to this peer, so the older one's end finds diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index afb9b7176..4108546b8 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -182,7 +182,10 @@ public class WifiDirectManager: NSObject, TransportManager { }, schedule: { [weak self] delay, block in self?.linkQueue.asyncAfter(deadline: .now() + delay, execute: block) - } + }, + // Multipeer does not say which side invited. Stop-gap until the + // Network-framework manager, which knows, replaces this one. + isOutbound: { _ in false } ) } diff --git a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift index 3506efedd..63c49999c 100644 --- a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift @@ -229,20 +229,34 @@ final class PeerStreamFramingTests: XCTestCase { private let a = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn" private let b = "off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn8antf" + /// This device, lower than both, so the streams it opens win. + private let me = "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + + private func announce( + _ links: PeerStreamLinks, _ handle: String, _ address: String, + outbound: Bool = true + ) -> PeerStreamLinks.Announcement { + links.announce(handle, address: address, outbound: outbound, localAddress: me) + } func testTheFirstStreamForAnAddressIsAnnounced() { let links = PeerStreamLinks() XCTAssertTrue(links.isEmpty) - XCTAssertEqual(links.announce("s1", address: a), .init(firstForAddress: true, superseded: nil)) + XCTAssertEqual(announce(links, "s1", a), .init(firstForAddress: true, superseded: nil)) XCTAssertEqual(links.handle(for: a), "s1") XCTAssertFalse(links.isEmpty) } + func testTheFirstStreamIsAnnouncedWhicheverKindItIs() { + let links = PeerStreamLinks() + XCTAssertEqual(announce(links, "in", a, outbound: false), .init(firstForAddress: true, superseded: nil)) + } + func testANewerStreamSupersedesAndTheCoreSeesOneAnnouncementAndOneLoss() { let links = PeerStreamLinks() - _ = links.announce("old", address: a) + _ = announce(links, "old", a) - let second = links.announce("new", address: a) + let second = announce(links, "new", a) XCTAssertFalse(second.firstForAddress, "a second announcement would tell the core nothing new") XCTAssertEqual(second.superseded, "old") XCTAssertEqual(links.handle(for: a), "new") @@ -257,23 +271,62 @@ final class PeerStreamFramingTests: XCTestCase { XCTAssertTrue(links.isEmpty) } + func testASecondStreamOfTheLosingKindIsRefusedAndTheHeldOneStays() { + let links = PeerStreamLinks() + _ = announce(links, "out", a) + XCTAssertEqual( + announce(links, "in", a, outbound: false), + .init(firstForAddress: false, superseded: nil, refused: true)) + XCTAssertEqual(links.handle(for: a), "out") + XCTAssertNil(links.remove("in"), "a refused stream was never announced") + XCTAssertEqual(links.remove("out"), a) + } + + func testTheWinningKindIsWhateverTheLowerAddressOpened() { + // Python's `_new_stream_wins`, case for case (ADR 0026). + XCTAssertTrue(PeerStreamLinks.newStreamWins(outbound: true, localAddress: me, peer: a)) + XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: false, localAddress: me, peer: a)) + XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: true, localAddress: a, peer: me)) + XCTAssertTrue(PeerStreamLinks.newStreamWins(outbound: false, localAddress: a, peer: me)) + XCTAssertTrue(PeerStreamLinks.newStreamWins(outbound: true, localAddress: b, peer: a), + "b sorts below a at the first differing character") + XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: true, localAddress: nil, peer: a)) + XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: false, localAddress: nil, peer: a)) + } + + func testBothEndsOfAPairKeepTheSameStream() { + // `mine` is the stream the lower address (me) opened; `theirs` the + // other. Each end sees one as outbound and the other as inbound. + for firstArrives in ["mine", "theirs"] { + let low = PeerStreamLinks() + let high = PeerStreamLinks() + let order = firstArrives == "mine" ? ["mine", "theirs"] : ["theirs", "mine"] + for stream in order { + _ = low.announce(stream, address: a, outbound: stream == "mine", localAddress: me) + _ = high.announce(stream, address: me, outbound: stream == "theirs", localAddress: a) + } + XCTAssertEqual(low.handle(for: a), "mine", firstArrives) + XCTAssertEqual(high.handle(for: me), "mine", firstArrives) + } + } + func testAStreamThatNeverProvedAPeerReportsNothing() { XCTAssertNil(PeerStreamLinks().remove("never-announced")) } func testAStreamIsReportedLostOnce() { let links = PeerStreamLinks() - _ = links.announce("s1", address: a) + _ = announce(links, "s1", a) XCTAssertEqual(links.remove("s1"), a) XCTAssertNil(links.remove("s1")) } func testASecondAnnouncementForOneStreamIsANoOp() { let links = PeerStreamLinks() - _ = links.announce("s1", address: a) - XCTAssertEqual(links.announce("s1", address: a), .init(firstForAddress: false, superseded: nil)) + _ = announce(links, "s1", a) + XCTAssertEqual(announce(links, "s1", a), .init(firstForAddress: false, superseded: nil)) // Another address is refused the same way, and the first stays held. - XCTAssertEqual(links.announce("s1", address: b), .init(firstForAddress: false, superseded: nil)) + XCTAssertEqual(announce(links, "s1", b), .init(firstForAddress: false, superseded: nil)) XCTAssertEqual(links.handle(for: a), "s1") XCTAssertNil(links.handle(for: b)) XCTAssertEqual(links.remove("s1"), a) @@ -282,17 +335,17 @@ final class PeerStreamFramingTests: XCTestCase { func testAddressesAreIndependent() { let links = PeerStreamLinks() - _ = links.announce("s1", address: a) - XCTAssertEqual(links.announce("s2", address: b), .init(firstForAddress: true, superseded: nil)) + _ = announce(links, "s1", a) + XCTAssertEqual(announce(links, "s2", b), .init(firstForAddress: true, superseded: nil)) XCTAssertEqual(links.remove("s1"), a) XCTAssertEqual(links.handle(for: b), "s2") } func testRemoveAllReturnsOnlyTheLiveStreams() { let links = PeerStreamLinks() - _ = links.announce("old", address: a) - _ = links.announce("new", address: a) - _ = links.announce("s2", address: b) + _ = announce(links, "old", a) + _ = announce(links, "new", a) + _ = announce(links, "s2", b) let live = links.removeAll().map { "\($0.handle)=\($0.address)" } XCTAssertEqual(Set(live), ["new=\(a)", "s2=\(b)"]) XCTAssertTrue(links.isEmpty) @@ -301,8 +354,8 @@ final class PeerStreamFramingTests: XCTestCase { func testReAnnouncingAStreamIsANoOp() { let links = PeerStreamLinks() - _ = links.announce("s1", address: a) - XCTAssertEqual(links.announce("s1", address: a), .init(firstForAddress: false, superseded: nil)) + _ = announce(links, "s1", a) + XCTAssertEqual(announce(links, "s1", a), .init(firstForAddress: false, superseded: nil)) XCTAssertEqual(links.handle(for: a), "s1") } } diff --git a/bindings/react-native/ios/tests/PeerStreamReaderTests.swift b/bindings/react-native/ios/tests/PeerStreamReaderTests.swift new file mode 100644 index 000000000..0361364d2 --- /dev/null +++ b/bindings/react-native/ios/tests/PeerStreamReaderTests.swift @@ -0,0 +1,79 @@ +// +// PeerStreamReaderTests.swift +// +// Pins how the iOS peer-stream manager cuts a TCP stream into frames +// (docs/spec/stream-framing.md, "What a receiver owes", steps one and two): +// whatever the chunking, each frame comes out whole, and a prefix outside the +// bounds is refused on its four bytes, before any body is buffered. Android +// does the same in PeerStreamFraming.readBody. +// + +import XCTest +@testable import OfflineProtocol + +final class PeerStreamReaderTests: XCTestCase { + + private func frame(_ body: Data) -> Data { PeerStreamFraming.frame(body)! } + + private func frames(_ results: [Result]) -> [Data] { + results.compactMap { try? $0.get() } + } + + func testAFrameSplitAtEveryByteComesOutWholeOnce() { + let whole = frame(Data("hello".utf8)) + let reader = PeerStreamReader() + var out: [Data] = [] + for byte in whole { + out += frames(reader.append(Data([byte]))) + } + XCTAssertEqual(out, [whole]) + } + + func testSeveralFramesInOneChunkComeOutInOrder() { + let one = frame(Data("one".utf8)) + let two = frame(Data(repeating: 7, count: 300)) + let three = frame(Data("three".utf8)) + let reader = PeerStreamReader() + var chunk = one + two + three + let tail = chunk.suffix(2) + chunk.removeLast(2) + XCTAssertEqual(frames(reader.append(chunk)), [one, two]) + XCTAssertEqual(frames(reader.append(Data(tail))), [three]) + } + + func testAFrameOfExactlyTheCeilingIsRead() { + let whole = frame(Data(count: PeerStreamFraming.maxBodyBytes)) + let out = PeerStreamReader().append(whole) + XCTAssertEqual(frames(out), [whole]) + } + + func testAPrefixOverTheCeilingIsRefusedBeforeAnyBody() { + let reader = PeerStreamReader() + // 1 MiB + 1, and not a byte of body sent. + let out = reader.append(Data([0x00, 0x10, 0x00, 0x01])) + XCTAssertEqual(out, [.failure(PeerStreamRefusal(reason: "length over the ceiling"))]) + // The stream is garbage from here: nothing more comes out. + XCTAssertTrue(reader.append(frame(Data("after".utf8))).isEmpty) + } + + func testAZeroLengthPrefixIsRefused() { + let out = PeerStreamReader().append(Data([0, 0, 0, 0, 1, 2, 3])) + XCTAssertEqual(out, [.failure(PeerStreamRefusal(reason: "zero length"))]) + } + + func testFramesBeforeARefusalAreStillReturned() { + let good = frame(Data("good".utf8)) + let out = PeerStreamReader().append(good + Data([0, 0, 0, 0])) + XCTAssertEqual(out, [.success(good), .failure(PeerStreamRefusal(reason: "zero length"))]) + } + + func testTheFloorIsLeftToUnframe() { + // A short first body is only wrong in the preamble position, which the + // reader does not know; `unframe` refuses it there. + let short = frame(Data("hi".utf8)) + XCTAssertEqual(frames(PeerStreamReader().append(short)), [short]) + XCTAssertEqual( + PeerStreamFraming.unframe(short, preamble: true), + .failure(PeerStreamRefusal(reason: "preamble under the floor"))) + } +} diff --git a/bindings/react-native/ios/tests/PeerStreamSessionTests.swift b/bindings/react-native/ios/tests/PeerStreamSessionTests.swift index e2e083eb6..2bcdd6d55 100644 --- a/bindings/react-native/ios/tests/PeerStreamSessionTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamSessionTests.swift @@ -2,15 +2,15 @@ // PeerStreamSessionTests.swift // // Drives PeerStreamSession with string handles, a fake carrier and a manual -// clock: the iOS Wi-Fi Direct manager's per-peer behaviour without a -// Multipeer session, which no CI runner has. Mirrors android's -// PeerStreamSocketsTest, keep in sync. +// clock: the iOS peer-stream manager's per-peer behaviour without a radio, +// which no CI runner has. A handle that starts with `out` is a stream this +// side opened. Mirrors android's PeerStreamSocketsTest, keep in sync. // // The verifier is a stand-in (the real one is Rust, needs the native library, // and is tested against the identity assertion vectors in Rust): an // "assertion" here is a `peer-…` address in ASCII, zero-padded to the 96-byte // floor, and nothing else verifies. What a test cannot see is the manager's -// session handling (advertising, browsing, inviting), which needs a device. +// connection handling (advertising, browsing, dialing), which needs a device. // The device run is recorded in the PR (docs/bridges C9). // @@ -85,7 +85,8 @@ final class PeerStreamSessionTests: XCTestCase { self.sent.append((handle, frame)) }, disconnect: { [unowned self] handle in self.disconnected.append(handle) }, - schedule: { [unowned self] delay, block in self.timers.append((delay, block)) } + schedule: { [unowned self] delay, block in self.timers.append((delay, block)) }, + isOutbound: { $0.hasPrefix("out") } ) } @@ -242,23 +243,80 @@ final class PeerStreamSessionTests: XCTestCase { // MARK: - One announced peer per address - func testANewerPeerSupersedesWithOneAnnouncementAndOneLoss() { - prove("old", as: "peer-b") - prove("new", as: "peer-b") - XCTAssertEqual(disconnected, ["old"]) + // This side is peer-a. peer-b is higher, so the streams this side opens + // win against it; peer-0 is lower, so its streams win against this side. + + func testANewerStreamOfTheWinningKindSupersedesWithOneAnnouncementAndOneLoss() { + // The lower address reconnecting past its own half-open stream. + prove("out-old", as: "peer-b") + prove("out-new", as: "peer-b") + XCTAssertEqual(disconnected, ["out-old"]) XCTAssertEqual(host.events, ["connected:peer-b"], "no second announcement, no loss") - XCTAssertEqual(session.handle(for: "peer-b"), "new") + XCTAssertEqual(session.handle(for: "peer-b"), "out-new") // The superseded peer's late message and its disconnect are silent. - session.received(frame(Data("late".utf8)), from: "old") - session.ended("old") + session.received(frame(Data("late".utf8)), from: "out-old") + session.ended("out-old") XCTAssertEqual(host.events, ["connected:peer-b"]) - session.received(frame(Data("fresh".utf8)), from: "new") - session.ended("new") + session.received(frame(Data("fresh".utf8)), from: "out-new") + session.ended("out-new") XCTAssertEqual(host.events, ["connected:peer-b", "message:peer-b:5", "lost:peer-b"]) } + func testAgainstAHigherAddressOurStreamIsKeptInEitherOrder() { + prove("in", as: "peer-b") + prove("out", as: "peer-b") + XCTAssertEqual(disconnected, ["in"], "ours supersedes theirs") + XCTAssertEqual(session.handle(for: "peer-b"), "out") + + session.ended("out") + session.ended("in") + disconnected = [] + host.events = [] + + prove("out", as: "peer-b") + prove("in", as: "peer-b") + XCTAssertEqual(disconnected, ["in"], "theirs is refused") + XCTAssertEqual(session.handle(for: "peer-b"), "out") + XCTAssertEqual(host.events, ["connected:peer-b"]) + } + + func testAgainstALowerAddressTheirStreamIsKeptInEitherOrder() { + prove("out", as: "peer-0") + prove("in", as: "peer-0") + XCTAssertEqual(disconnected, ["out"], "theirs supersedes ours") + XCTAssertEqual(session.handle(for: "peer-0"), "in") + + session.ended("in") + session.ended("out") + disconnected = [] + host.events = [] + + prove("in", as: "peer-0") + prove("out", as: "peer-0") + XCTAssertEqual(disconnected, ["out"], "ours is refused") + XCTAssertEqual(session.handle(for: "peer-0"), "in") + XCTAssertEqual(host.events, ["connected:peer-0"]) + } + + func testARefusedDuplicateIsSilentAndTheHeldStreamStaysLive() { + prove("out", as: "peer-b") + prove("in", as: "peer-b") + XCTAssertEqual(disconnected, ["in"]) + + // Nothing from the refused stream reaches the host, and its end + // reports nothing: the address is still held. + session.received(frame(Data("late".utf8)), from: "in") + session.ended("in") + XCTAssertEqual(host.events, ["connected:peer-b"]) + + session.received(frame(Data("fresh".utf8)), from: "out") + XCTAssertEqual(host.events, ["connected:peer-b", "message:peer-b:5"]) + fireTimers() + XCTAssertEqual(disconnected, ["in"], "the held stream proved itself; no deadline fires") + } + func testEndAllReportsEachAnnouncedPeerOnce() { prove("h1", as: "peer-b") prove("h2", as: "peer-c") diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 87ae69fbe..bcf41a38a 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17039,6 +17039,51 @@ mod tests { ); } + /// The iOS and Python peer-stream managers keep the same one of two + /// streams for an address. + /// + /// Both ends of a pair may dial, and a Python host on a LAN dials every + /// peer it discovers, so the choice of which stream to keep is a + /// hand-mirrored policy (docs/bridges C5): the stream the lower address + /// opened wins. If the two copies drift, an iPhone and a Python host each + /// keep the stream the other closes, and the pair reconnects forever, + /// with no error on either side (ADR 0026). Pinned as the two lines each + /// rule is made of. Android keeps the newer stream instead, because a + /// Wi-Fi Direct group has one dialer. + #[test] + fn ios_and_python_peer_streams_keep_the_same_stream() { + let swift = rn_source_code_only("ios/PeerStreamFraming.swift"); + let python_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../bindings/python/offline_protocol_sdk/peer_stream_manager.py"); + let python = std::fs::read_to_string(&python_path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", python_path.display())); + + for needed in [ + "guard let local = localAddress else { return false }", + "let weOpen = local.utf8.lexicographicallyPrecedes(peer.utf8) \ + return outbound == weOpen", + "if byAddress[address] != nil, \ + !Self.newStreamWins(outbound: outbound, localAddress: localAddress, peer: address) { \ + return Announcement(firstForAddress: false, superseded: nil, refused: true) }", + ] { + assert!( + swift.contains(needed), + "ios/PeerStreamFraming.swift: the stream the lower address opened must win, \ + as in peer_stream_manager.py. Expected to find:\n {needed}" + ); + } + for needed in [ + "if local is None:\n # No address of our own to order by; keep what is announced.\n return False", + "we_open = local < peer\n return new.outbound == we_open", + ] { + assert!( + python.contains(needed), + "peer_stream_manager.py: the stream the lower address opened must win, as in \ + ios/PeerStreamFraming.swift. Expected to find:\n {needed}" + ); + } + } + /// Wi-Fi Direct hands the core only an address a preamble proved. /// /// Both managers used to pass a transport-level string, a TCP endpoint on @@ -17110,7 +17155,11 @@ mod tests { // the core send at once, on another queue. "case .announce(let address): \ guard sendPreambleIfNeeded(handle, link) else { return } \ - let announcement = links.announce(handle, address: address)", + let announcement = links.announce( handle, address: address, \ + outbound: isOutbound(handle), localAddress: host.peerStreamLocalAddress())", + // The losing kind of a duplicate announces nothing and + // reports nothing: the held stream keeps the address. + "if announcement.refused { link.refused = true disconnect(handle)", // A late disconnect from a replaced session must not end // the same MCPeerID's link in the new one. "case .notConnected: guard self.session === session else { return } \ From c8498f3631c9a83e89e618e49760b03014b3c4e9 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Wed, 30 Sep 2026 22:39:18 +0400 Subject: [PATCH 02/16] feat(bindings): the iOS peer stream rides Network framework instead of 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. --- .github/workflows/ci.yml | 2 +- bindings/react-native/MeshSdk.podspec | 2 +- .../ios/OfflineProtocolModule.swift | 5 +- .../react-native/ios/WifiDirectManager.swift | 926 ++++++++++-------- crates/offline-protocol-uniffi/src/lib.rs | 88 +- .../react-native-app/INTEGRATION_GUIDE.md | 1 - .../ios/OfflineProtocolExample/Info.plist | 1 - 7 files changed, 573 insertions(+), 452 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f0ee3cb..d7d0a23cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -817,7 +817,7 @@ jobs: run: swift test --package-path bindings/react-native/ios # The largest bridge files are on Package.swift's `exclude:` list (they - # need CoreBluetooth / MultipeerConnectivity / React / the generated + # need CoreBluetooth / Network / React / the generated # UniFFI module, none of which the SwiftPM harness can supply), so # `swift test` above compiles none of them and every change to them has # historically shipped with zero compile coverage. They CAN be diff --git a/bindings/react-native/MeshSdk.podspec b/bindings/react-native/MeshSdk.podspec index 14f84c1cc..4da4f88f0 100644 --- a/bindings/react-native/MeshSdk.podspec +++ b/bindings/react-native/MeshSdk.podspec @@ -106,7 +106,7 @@ Pod::Spec.new do |s| } # System libraries and frameworks - s.frameworks = "Foundation", "CoreBluetooth", "MultipeerConnectivity" + s.frameworks = "Foundation", "CoreBluetooth", "Network" # React Native dependency s.dependency "React-Core" diff --git a/bindings/react-native/ios/OfflineProtocolModule.swift b/bindings/react-native/ios/OfflineProtocolModule.swift index a08d2f14b..716cd3942 100644 --- a/bindings/react-native/ios/OfflineProtocolModule.swift +++ b/bindings/react-native/ios/OfflineProtocolModule.swift @@ -192,8 +192,9 @@ class OfflineProtocolModule: RCTEventEmitter { // MARK: - iOS background / Wi‑Fi suspension - /// When the app enters background, iOS kills MultipeerConnectivity. Notify Rust so DORS - /// stops routing over Wi‑Fi Direct and uses BLE (allowed in background). + /// When the app enters background, iOS suspends the peer-stream listener and its + /// connections. Notify Rust so DORS stops routing over the peer-stream slot and uses + /// BLE (allowed in background). private func addBackgroundObservers() { NotificationCenter.default.addObserver( self, diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 4108546b8..73313d09b 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -2,142 +2,166 @@ // WifiDirectManager.swift // OfflineProtocol // -// WiFi Direct transport implementation using MultipeerConnectivity -// Note: iOS uses MultipeerConnectivity framework which provides similar -// functionality to Android's WiFi Direct (Wi-Fi P2P) +// The iOS peer-stream transport: TCP streams over Network framework, found +// through DNS-SD, on the infrastructure network or over AWDL (Apple's +// peer-to-peer Wi-Fi) when there is none. The name is the engine slot's, not +// the radio's: iOS has no Wi-Fi Direct API (ADR 0026). // import Foundation -import MultipeerConnectivity +import Network -/// WiFi Direct Manager implementing TransportManager for peer-to-peer WiFi communication -/// Uses Apple's MultipeerConnectivity framework which provides WiFi Direct-like functionality +/// The engine's peer-stream slot on iOS, over `NWListener`, `NWBrowser` and +/// `NWConnection`. /// /// ## Every peer proves its address before it carries anything /// -/// The engine registers this transport as its peer-stream slot, and -/// `docs/spec/stream-framing.md` is the contract. When a Multipeer peer -/// connects, each side sends its identity assertion as its first message and -/// verifies the other's with `verifyIdentityAssertion`, the one verifier every +/// `docs/spec/stream-framing.md` is the contract, and this manager is a plain +/// implementation of it: a TCP stream to one peer, a `u32` big-endian length +/// before every body, and the identity assertion as the first frame in each +/// direction, checked with `verifyIdentityAssertion`, the one verifier every /// platform uses. The address it derives is the only id this manager hands the -/// core: to `wifiDirectPeerConnected`, as the `senderId` of each body, and to -/// `wifiDirectPeerDisconnected`. `MCPeerID.displayName` never reaches the core. -/// It carries the remote's app-chosen profile, which nothing binds to a key and -/// which is commonly a shared constant, and announcing it once put unprovable -/// ids into the core's capacity-bounded `known_peers`. +/// core. The discovery record's `addr` is a hint the preamble must prove, +/// never a name to announce. /// /// What the chapter asks of a receiver lives in `PeerStreamSession`, which -/// this manager forwards each per-peer event to: every message is one frame -/// whose `u32` big-endian prefix equals the rest of the message (the session -/// already delivers whole messages), the preamble must verify within -/// `PREAMBLE_TIMEOUT`, and one peer is announced per address, the newer -/// superseding the older without a loss report. It is Foundation-only and -/// unit-tested with a fake session and clock. What stays here: -/// - The service type is `offlineprotocol`, which the framework publishes as -/// `_offlineprotocol._tcp` and `_offlineprotocol._udp`; an app's -/// `NSBonjourServices` must list both, or local-network privacy blocks -/// discovery with no error. The discovery info carries `txtvers` and `addr`, -/// and `addr` is a claim the preamble must prove for a peer we invite. +/// this manager forwards each per-stream event to, after `PeerStreamReader` +/// has cut the stream into whole frames. Both are Foundation-only and +/// unit-tested with fakes. What stays here: +/// - The service is `_offlineprotocol._tcp` with `txtvers=1` first and `addr`, +/// the same record the Python `PeerStreamManager` publishes, so an iPhone +/// and a host on one LAN find each other. An app's `NSBonjourServices` must +/// list `_offlineprotocol._tcp`, or local-network privacy blocks discovery; +/// the browser reports that as a diagnostic instead of failing silently. +/// - Both ends of a pair may dial. Which of two streams for one address is +/// kept is decided by `PeerStreamLinks`, the same way on both ends, so the +/// lower address dials at once and the higher waits `HIGHER_ADDRESS_DIAL_DELAY` +/// to see whether the lower one reached it first. Without the wait every +/// first contact would open two streams and close one. /// -/// Every per-peer transition (connect, each received message, disconnect) runs -/// on `linkQueue`, one serial queue, so a peer's first message cannot overtake -/// its connect callback and no body reaches the core after its loss report. -/// The core re-adds a neighbour on any inbound body, so that ordering is what -/// keeps a reported-lost peer lost. Mirrors android's WifiDirectManager.kt. +/// Everything Network framework calls back with, and every per-stream step, +/// runs on `linkQueue`, one serial queue: the listener, the browser and every +/// connection are started on it. So a stream's first frame cannot overtake its +/// connect, and no body reaches the core after its loss report. The core +/// re-adds a neighbour on any inbound body, so that ordering is what keeps a +/// reported-lost peer lost. public class WifiDirectManager: NSObject, TransportManager { - + // MARK: - TransportManager Protocol - + public let transportId = "wifi_direct" - public let transportName = "WiFi Direct (MultipeerConnectivity)" - /// Read through [stateLock] like the session below, because - /// it is touched by the same three threads: the lifecycle writes it from - /// the bridge queue, and the send path reads it from whichever thread the - /// Rust callback arrives on. This is the iOS half of the `@Volatile` the - /// Android manager's `state` carries, and it is what makes the claim on - /// [onMessagesAvailable] — that the drain's reads are safe from anywhere — - /// actually true. + public let transportName = "Peer stream (Network framework)" + /// Read through [stateLock], because it is touched by three threads: the + /// lifecycle writes it from the bridge queue, and the send path reads it + /// from whichever thread the Rust callback arrives on. This is the iOS + /// half of the `@Volatile` the Android manager's `state` carries. public var state: TransportState { stateLock.lock(); defer { stateLock.unlock() } return _state } public weak var delegate: TransportManagerDelegate? - - // MARK: - Constants - - /// MCSession has a hard limit of 8 peers. Stay at 7 to avoid overwhelming the daemon - /// and prevent connection storms in dense environments (e.g. festival, protest). - private let WIFI_DIRECT_MAX_PEERS_SAFE = 7 - /// Returns true when at or over the connection budget (so we must not invite or accept). - /// Used in browser and advertiser delegates; exposed for unit tests. - static func atConnectionBudgetLimit(connectedCount: Int) -> Bool { - return connectedCount >= 7 - } + // MARK: - Constants - /// `offlineprotocol`: fifteen characters, the Multipeer maximum, and the - /// service label stream-framing.md requires. It was `offline-proto`, which - /// no app's `NSBonjourServices` listed under the documented name. - private let SERVICE_TYPE = "offlineprotocol" + /// Fifteen characters, the most a DNS-SD service label allows, and the + /// type stream-framing.md requires. + static let SERVICE_TYPE = "_offlineprotocol._tcp" /// How long a connected peer may take to prove its address. Local policy, /// not wire format (stream-framing.md). private let PREAMBLE_TIMEOUT: TimeInterval = 10.0 - private let DISCOVERY_TIMEOUT: TimeInterval = 30.0 - private let CONNECTION_TIMEOUT: TimeInterval = 30.0 - + /// Open streams, proved or not. Android's `Limits.maxStreams`. + private static let MAX_STREAMS = 16 + /// How long the higher address of a pair waits before dialing, so that + /// the lower one's stream, which would win, usually arrives first. + private static let HIGHER_ADDRESS_DIAL_DELAY: TimeInterval = 5.0 + private static let REDIAL_INITIAL_DELAY: TimeInterval = 1.0 + private static let REDIAL_MAX_DELAY: TimeInterval = 60.0 + /// A listener or browser that failed is rebuilt after this long. + private static let REBUILD_DELAY: TimeInterval = 5.0 + /// Android's `Limits`: queued bytes toward one peer beyond which a body + /// is dropped (the core retries it), and only while that peer's oldest + /// write has been outstanding for `WRITE_STALL_MS`. The core hands a burst + /// over at once, so a bound applied regardless drops frames for a peer + /// that is reading. + private static let MAX_QUEUED_BYTES = 4 * PeerStreamFraming.maxBodyBytes + private static let WRITE_STALL_MS: Int64 = 2_000 + /// A write outstanding this long ends the stream. + // ponytail: per write, not per unit of progress as on Android, so a 1 MiB + // frame needs about 35 KB/s. AWDL and a LAN are far above that; move to + // chunked progress if a slow link ever trips it. + private static let WRITE_TIMEOUT_MS: Int64 = 30_000 + /// `kDNSServiceErr_PolicyDenied`: local-network privacy refused us. + private static let DNS_POLICY_DENIED: Int32 = -65570 + // MARK: - Properties - + private let protocolInstance: OfflineProtocol private let deviceId: String - - // MultipeerConnectivity components - private var advertiser: MCNearbyServiceAdvertiser? - private var browser: MCNearbyServiceBrowser? // Message sending (event-driven, no polling) private let messageQueue = DispatchQueue(label: "com.offlineprotocol.wifidirect.messages") - /// Every per-peer transition runs here, in order: the connect, each - /// received message, the disconnect, and the preamble deadline. See the - /// type's documentation for why one serial queue is the invariant. + /// Every Network framework callback and every per-stream step runs here. + /// See the type's documentation for why one serial queue is the invariant. private let linkQueue = DispatchQueue(label: "com.offlineprotocol.wifidirect.links") private static let linkQueueKey = DispatchSpecificKey() - /// Each connected peer from its first message to its disconnect: the - /// preamble, the framing, one announced peer per address. This manager - /// owns the session and forwards per-peer events on `linkQueue`; see - /// `PeerStreamSession` for the rest. Set once in `init`, before any - /// callback can arrive, and never reassigned. - private var peers: PeerStreamSession! - - /// Guards [_state], [_session] and [_isPaused], which three different - /// threads touch: the lifecycle writes them from the bridge queue, the - /// MCSession / browser / advertiser delegates from MultipeerConnectivity's - /// own queues, and the send path reads them from [messageQueue]. The proved - /// peers live in `links`, which has its own lock under the same rule. They were - /// plain properties, which is the same unsynchronised cross-thread access - /// #338 removed from BleManager's `peripheralRSSI`. + /// Each stream from its first frame to its end: the preamble, the + /// framing, one announced stream per address. Set once in `init`, before + /// any callback can arrive, and never reassigned. + private var peers: PeerStreamSession! + + /// One TCP stream. A new one per connection and never reused, so no state + /// from an earlier stream can be mistaken for this one's. + private final class Stream: Hashable { + let connection: NWConnection + /// Whether this device opened it, which decides which of two streams + /// for one address is kept. + let outbound: Bool + /// The address an outbound stream was dialed toward, for the redial. + let dialed: String? + let reader = PeerStreamReader() + let writes = WriteStallWatchdog(timeoutMs: WifiDirectManager.WRITE_STALL_MS) + var queuedBytes = 0 + + init(connection: NWConnection, outbound: Bool, dialed: String?) { + self.connection = connection + self.outbound = outbound + self.dialed = dialed + } + + static func == (lhs: Stream, rhs: Stream) -> Bool { lhs === rhs } + func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } + } + + // linkQueue only, all of it. + private var listener: NWListener? + private var browser: NWBrowser? + private var streams = Set() + /// The endpoint each advertised address is reachable at, while it is. + private var adverts: [String: NWEndpoint] = [:] + /// Addresses with an outbound stream open or a dial scheduled. + private var dialing = Set() + private var redialDelay: [String: TimeInterval] = [:] + /// Bumped by every start() and stop(), so a timer armed for one run finds + /// nothing to act on in the next. + private var generation = 0 + + /// Guards [_state] and [_isPaused], which the lifecycle writes from the + /// bridge queue and the send path reads from any thread. Everything + /// Network framework touches lives on `linkQueue` instead, and the proved + /// peers in `PeerStreamLinks`, under its own lock and the same rule. /// - /// Held across one field read or write and nothing else — never across a - /// UniFFI call, an `MCSession.send`, or a delegate callback. Every accessor - /// below returns a snapshot so callers work on values, not on shared - /// storage. That is also why it can be a plain [NSLock]: no accessor calls - /// out, so nothing can re-enter and need recursion. + /// Held across one field read or write and nothing else: never across a + /// UniFFI call, a send, or a delegate callback. That is also why it can be + /// a plain [NSLock]: no accessor calls out, so nothing can re-enter. private let stateLock = NSLock() private var _state: TransportState = .unavailable /// True between `pause()` and `resume()`. Mirrors `InternetManager`'s flag - /// of the same name. - /// - /// This manager's `pause()` stopped browsing and nothing else, so the send - /// path ignored it entirely: `onMessagesAvailable` runs - /// `drainAndSendMessages`, which gated on `state` — and pause does not - /// change `state` — so a paused transport drained the whole queue, each - /// message taking the core's global protocol mutex. Guarded here rather - /// than only at the callback because `drainAndSendMessages` is also reached - /// from `resume()`, and because the drain loop re-reads it every iteration. + /// of the same name: pausing stops discovery and the send path, which the + /// drain loop re-reads every iteration. private var _isPaused = false - private var _session: MCSession? private func setState(_ newState: TransportState) { stateLock.lock(); defer { stateLock.unlock() } @@ -149,11 +173,6 @@ public class WifiDirectManager: NSObject, TransportManager { set { stateLock.lock(); defer { stateLock.unlock() }; _isPaused = newValue } } - private var session: MCSession? { - get { stateLock.lock(); defer { stateLock.unlock() }; return _session } - set { stateLock.lock(); defer { stateLock.unlock() }; _session = newValue } - } - /// Whether any peer has proved an address. Reads `peers`, not /// `stateLock`: the proved peers are the only ones there is anything to /// send to. @@ -161,51 +180,82 @@ public class WifiDirectManager: NSObject, TransportManager { return !peers.isEmpty } - private var transportStartAt: Date? - // MARK: - Initialization - + public init(protocol protocolInstance: OfflineProtocol, deviceId: String) { self.protocolInstance = protocolInstance self.deviceId = deviceId super.init() linkQueue.setSpecific(key: Self.linkQueueKey, value: true) - peers = PeerStreamSession( + peers = PeerStreamSession( host: self, preambleTimeout: PREAMBLE_TIMEOUT, - send: { [weak self] frame, peer in - guard let session = self?.session else { throw TransportError.notRunning } - try session.send(frame, toPeers: [peer], with: .reliable) + send: { [weak self] frame, stream in + guard let self = self, self.write(frame, to: stream) else { + throw TransportError.notRunning + } }, - disconnect: { [weak self] peer in - self?.session?.cancelConnectPeer(peer) + disconnect: { stream in + // Its `.cancelled` then ends it, and finds nothing to report. + stream.connection.cancel() }, schedule: { [weak self] delay, block in self?.linkQueue.asyncAfter(deadline: .now() + delay, execute: block) }, - // Multipeer does not say which side invited. Stop-gap until the - // Network-framework manager, which knows, replaces this one. - isOutbound: { _ in false } + isOutbound: { $0.outbound } ) } - + deinit { stop() } - + + /// TCP with keepalive, over AWDL as well as the infrastructure network. + /// The one place a carrier is chosen: Wi-Fi Aware, when it comes, starts + /// here (ADR 0026). Keepalive is the Python manager's: a stream whose peer + /// died idle ends within about thirty seconds, which is what lets the + /// losing kind of reconnect through once the stale winner is gone. + private static func makeParameters() -> NWParameters { + let tcp = NWProtocolTCP.Options() + tcp.enableKeepalive = true + tcp.keepaliveIdle = 15 + tcp.keepaliveInterval = 5 + tcp.keepaliveCount = 3 + tcp.connectionTimeout = 10 + let parameters = NWParameters(tls: nil, tcp: tcp) + parameters.includePeerToPeer = true + return parameters + } + + /// The DNS-SD TXT record, built by hand because the chapter requires + /// `txtvers=1` to be the first entry and `NWTXTRecord` does not promise an + /// order. `addr` is absent until this device has an identity. + private static func txtRecord(address: String?) -> Data { + var entries = ["txtvers=1"] + if let address = address, !address.isEmpty { + entries.append("addr=\(address)") + } + var out = Data() + for entry in entries { + let bytes = Data(entry.utf8) + out.append(UInt8(bytes.count)) + out.append(bytes) + } + return out + } + // MARK: - TransportManager Implementation - + public func isAvailable() -> Bool { - // MultipeerConnectivity is available on all iOS devices return true } - + public func start() throws { guard state != .running else { throw TransportError.alreadyRunning } - - emitDiagnostic("info", "Starting WiFi Direct transport", context: [ + + emitDiagnostic("info", "Starting peer-stream transport", context: [ "deviceId": deviceId ]) @@ -213,108 +263,78 @@ public class WifiDirectManager: NSObject, TransportManager { // must not leave this fresh transport connected-but-mute. Mirrors // `InternetManager.start()`. isPaused = false - updateState(.starting) - transportStartAt = Date() - - // A fresh MCPeerID per start, never one per manager. PeerStreamSession - // keys each peer's state by its MCPeerID, and a remote that stopped - // and started again under the same one could have its new connect - // land before the old session's disconnect: the connect would find a - // preamble already sent and an address already proved, send nothing, - // and the remote would refuse us at its deadline. A new id makes every - // restart a new peer, which the supersede rule already handles. - let peerId = MCPeerID(displayName: deviceId) - - // Create session - session = MCSession( - peer: peerId, - securityIdentity: nil, - encryptionPreference: .required - ) - session?.delegate = self - - // Start advertising. `addr` is a hint a browser checks the preamble - // against, and absent until this device has an identity. The profile - // (`deviceId`) is no longer advertised: it is not an identity. - var discoveryInfo = ["txtvers": "1"] - if let address = protocolInstance.localAddress(), !address.isEmpty { - discoveryInfo["addr"] = address - } - advertiser = MCNearbyServiceAdvertiser( - peer: peerId, - discoveryInfo: discoveryInfo, - serviceType: SERVICE_TYPE - ) - advertiser?.delegate = self - advertiser?.startAdvertisingPeer() - // Start browsing - browser = MCNearbyServiceBrowser(peer: peerId, serviceType: SERVICE_TYPE) - browser?.delegate = self - browser?.startBrowsingForPeers() + var failure: Error? + onLinkQueueSync { + generation += 1 + do { + try startListening() + startBrowsing() + } catch { + failure = error + } + } + if let failure = failure { + updateState(.stopped) + throw TransportError.startFailed(failure.localizedDescription) + } updateState(.running) - - // Notify protocol try? protocolInstance.wifiDirectStatusChanged(isConnected: true) - - emitDiagnostic("info", "WiFi Direct transport started") + emitDiagnostic("info", "Peer-stream transport started") } - + public func stop() { guard state == .running || state == .starting else { return } - + updateState(.stopping) - - // Stop browsing - browser?.stopBrowsingForPeers() - browser = nil - // Stop advertising - advertiser?.stopAdvertisingPeer() - advertiser = nil - - // The session is forgotten first. The state-change and data callbacks - // check `self.session === session` on linkQueue, so from here a .connected - // that was already queued finds nothing to attach to. Cleared after - // endAll(), such a callback created a link in the just-emptied table, - // start() never clears it, and a remote that kept its MCPeerID met a - // stale refused link after a restart: no preamble, a refusal at its - // deadline, and one wasted round before the disconnect cleared it. - let old = session - session = nil - - // Report every proved peer lost while the core still holds its link, - // on linkQueue so no delivery lands after a loss report. The session's - // own .notConnected callbacks then find nothing left to report. onLinkQueueSync { + // Everything is forgotten first. Each callback checks that its + // listener, browser or stream is still this manager's, so from + // here one already queued finds nothing to attach to; forgotten + // after endAll(), a stream that became ready in between would be + // announced into the just-emptied table. + generation += 1 + let old = (listener: listener, browser: browser, streams: streams) + listener = nil + browser = nil + streams = [] + adverts = [:] + dialing = [] + redialDelay = [:] + + // Report every proved peer lost while the core still holds its + // link, before the layer goes down, on linkQueue so no delivery + // lands after a loss report. peers.endAll() + + old.listener?.cancel() + old.browser?.cancel() + old.streams.forEach { $0.connection.cancel() } } - old?.disconnect() - - // Notify protocol try? protocolInstance.wifiDirectStatusChanged(isConnected: false) - + updateState(.stopped) - emitDiagnostic("info", "WiFi Direct transport stopped") + emitDiagnostic("info", "Peer-stream transport stopped") } - + public func pause() { - // Set before browsing stops. This is what actually pauses the send - // path — see `isPaused`; stopping the browser only stops finding new - // peers, and this manager has no timer to cancel. + // Set before discovery stops. This is what actually pauses the send + // path, see `isPaused`; stopping the browser only stops finding new + // peers. The listener stays up, so a peer can still reach us. isPaused = true - browser?.stopBrowsingForPeers() + onLinkQueueSync { stopBrowsing() } } public func resume() { isPaused = false if state == .running { - browser?.startBrowsingForPeers() + onLinkQueueSync { startBrowsing() } // Drain any messages that accumulated while paused. Required // rather than tidy: the core does not re-issue // `onMessagesAvailable` for messages it already announced, and @@ -322,272 +342,359 @@ public class WifiDirectManager: NSObject, TransportManager { drainAndSendMessages() } } - + // MARK: - Message Handling (Event-Driven) - - /// Called by the Rust transport callback when new outgoing messages are available. - /// Replaces timer-based `startMessagePolling`. - /// - /// Goes straight to the drain rather than hopping through main first, as - /// the Reticulum and Nostr managers already do. The hop bought nothing: - /// the only work it did on main was read `state` and the peer map, both of - /// which are now synchronised and readable from anywhere, and it put a - /// scheduling dependency on the UI thread into the send path of a - /// transport that never touches the UI. + + /// Called by the Rust transport callback when new outgoing messages are + /// available. Goes straight to the drain: `state` and the proved peers are + /// both readable from any thread. public func onMessagesAvailable() { drainAndSendMessages() } - /// Drains the Rust message queue and sends each message over MultipeerConnectivity. + /// Drains the Rust message queue, framing each body and handing it to + /// `linkQueue` to write. /// /// Unbounded, where the Android manager's mirror of this spends a batch - /// budget and reposts. The asymmetry is deliberate: the budget there exists - /// because that looper is shared — it also delivers the Wi-Fi P2P framework - /// callbacks and the broadcast receiver, and a broadcast that misses its - /// dispatch budget is an ANR wherever the receiver runs — and because a - /// `stop()` waits on that same thread without a bound. Neither holds here. - /// `messageQueue` is this manager's alone, carries no framework callbacks, - /// and no lifecycle path waits on it, so a long drain delays nothing but - /// the next drain. Add a budget here only if one of those three facts - /// changes. - /// - /// Unbounded is not the same as unconditional, though, which is the one - /// thing the Android mirror gets for free and this does not. Re-entering - /// `drainAndSendMessages` after each batch re-runs its guard; a single - /// `while` does not, so the state has to be re-read *inside* the loop. The - /// guard below runs before the hop, and a `stop()` landing after it leaves - /// every remaining iteration taking the core's global mutex to fetch a - /// message that `sendMessage` then drops for want of a session — a warning - /// per message, against a transport that is already down. + /// budget and reposts. The budget there exists because that looper is + /// shared with the framework callbacks; `messageQueue` is this manager's + /// alone, and no lifecycle path waits on it. Unbounded is not + /// unconditional, though: the state and `isPaused` are re-read inside the + /// loop, because a `stop()` or `pause()` landing after the guard would + /// otherwise leave every remaining iteration taking the core's global + /// mutex for a message that is then dropped. private func drainAndSendMessages() { guard !isPaused, state == .running, hasConnectedPeers else { return } messageQueue.async { [weak self] in guard let self = self else { return } - - // `isPaused` re-read every iteration alongside the state, and for - // the same reason the state is: this loop can run for as long as - // the queue is deep, and a `pause()` landing inside it would - // otherwise keep taking the core's global protocol mutex once per - // remaining message against a transport the app has backgrounded. while !self.isPaused, self.state == .running, let message = self.protocolInstance.wifiDirectGetNextMessage() { self.sendMessage(recipientId: message.recipientId, data: Data(message.data)) } } } - - /// Frames one body and sends it to the peer that proved `recipientId`. + + /// Frames one body and writes it to the stream that proved `recipientId`. /// - /// Never broadcasts. The core returns a body only for an address a peer - /// proved, so a recipient with no peer here means that peer left between - /// the core's answer and this lookup; the body is dropped and the core's - /// acknowledgement and retry cover it. The broadcast fallback this - /// replaced handed a message for one peer to every peer in the session. + /// Never broadcasts. The core returns a body only for an address a stream + /// proved, so a recipient with no stream here means it ended between the + /// core's answer and this lookup; the body is dropped and the core's + /// acknowledgement and retry cover it. private func sendMessage(recipientId: String, data: Data) { - guard let session = session else { - emitDiagnostic("warning", "Cannot send message - no session") - return - } - guard let peer = peers.handle(for: recipientId) else { - emitDiagnostic("warning", "Wi-Fi Direct body dropped: no peer holds the recipient", context: [ - "recipientId": recipientId, - "dataSize": data.count - ]) - return - } guard let frame = PeerStreamFraming.frame(data) else { - emitDiagnostic("error", "Wi-Fi Direct body dropped: outside the frame bounds", context: [ + emitDiagnostic("error", "Peer-stream body dropped: outside the frame bounds", context: [ "recipientId": recipientId, "dataSize": data.count ]) return } - do { - try session.send(frame, toPeers: [peer], with: .reliable) - } catch { - emitDiagnostic("error", "Failed to send message", context: [ - "recipientId": recipientId, - "error": error.localizedDescription + linkQueue.async { [weak self] in + guard let self = self else { return } + guard let stream = self.peers.handle(for: recipientId), self.streams.contains(stream) else { + self.emitDiagnostic("warning", "Peer-stream body dropped: no stream holds the recipient", context: [ + "recipientId": recipientId, + "dataSize": data.count + ]) + return + } + if !self.write(frame, to: stream) { + self.emitDiagnostic("warning", "Peer-stream body dropped: the peer is not reading", context: [ + "recipientId": recipientId, + "dataSize": data.count + ]) + } + } + } + + /// Writes one frame, or returns false when the peer is stalled and its + /// queue is over the bound. `linkQueue` only; the completion and the + /// deadline run there too, since the connection was started on it. + private func write(_ frame: Data, to stream: Stream) -> Bool { + let now = MonotonicClock.nowMs() + if stream.queuedBytes + frame.count > Self.MAX_QUEUED_BYTES, + stream.writes.stalledAgeMs(nowMs: now) != nil { + return false + } + stream.queuedBytes += frame.count + let token = stream.writes.arm(nowMs: now) + stream.connection.send(content: frame, completion: .contentProcessed { [weak stream] error in + guard let stream = stream else { return } + stream.queuedBytes -= frame.count + stream.writes.disarm(token) + // A failed write ends the stream; its `.failed` or `.cancelled` + // does the bookkeeping. + if error != nil { stream.connection.cancel() } + }) + linkQueue.asyncAfter(deadline: .now() + .milliseconds(Int(Self.WRITE_TIMEOUT_MS))) { [weak self, weak stream] in + guard let self = self, let stream = stream, self.streams.contains(stream), + let age = stream.writes.stalledAgeMs(nowMs: MonotonicClock.nowMs()), + age >= Self.WRITE_TIMEOUT_MS else { return } + self.emitDiagnostic("warning", "Peer stream closed: a write made no progress", context: [ + "ageMs": age ]) + stream.connection.cancel() } + return true } - // MARK: - Peer links (linkQueue only) + // MARK: - Listening and discovery (linkQueue only) - /// Runs `body` on `linkQueue` and waits, or inline when already there. - /// Inline matters: `deinit` calls `stop()`, and the last reference can be - /// released inside a `linkQueue` block, where a `sync` onto the same - /// queue would trap. - private func onLinkQueueSync(_ body: () -> Void) { - if DispatchQueue.getSpecific(key: Self.linkQueueKey) == true { - body() - } else { - linkQueue.sync(execute: body) + private func startListening() throws { + let listener = try NWListener(using: Self.makeParameters()) + listener.service = NWListener.Service( + name: "op-\(UUID().uuidString.prefix(8).lowercased())", + type: Self.SERVICE_TYPE, + domain: nil, + txtRecord: Self.txtRecord(address: protocolInstance.localAddress()) + ) + listener.stateUpdateHandler = { [weak self, weak listener] newState in + guard let self = self, let listener = listener, listener === self.listener else { return } + self.listenerChanged(newState) + } + listener.newConnectionHandler = { [weak self, weak listener] connection in + guard let self = self, let listener = listener, listener === self.listener, + self.streams.count < Self.MAX_STREAMS else { + connection.cancel() + return + } + self.open(Stream(connection: connection, outbound: false, dialed: nil)) + } + self.listener = listener + listener.start(queue: linkQueue) + } + + private func listenerChanged(_ newState: NWListener.State) { + switch newState { + case .waiting(let error): + reportNetworkError("Peer-stream listener waiting", error) + case .failed(let error): + // Suspension in the background ends a listener this way. + reportNetworkError("Peer-stream listener failed", error) + listener?.cancel() + listener = nil + let expected = generation + linkQueue.asyncAfter(deadline: .now() + Self.REBUILD_DELAY) { [weak self] in + guard let self = self, self.generation == expected, self.listener == nil else { return } + do { + try self.startListening() + } catch { + self.emitDiagnostic("error", "Peer-stream listener could not restart", context: [ + "error": error.localizedDescription + ]) + } + } + default: + break } } - // MARK: - State Management - - private func updateState(_ newState: TransportState) { - setState(newState) - // Deliberately outside the lock: the delegate is the bridge module, - // and calling into it while holding [stateLock] would put arbitrary - // downstream work — including UniFFI calls — inside this manager's - // critical section. - delegate?.transportManager(self, didChangeState: newState) - } - - // MARK: - Diagnostics - - private func emitDiagnostic(_ level: String, _ message: String, context: [String: Any] = [:]) { - delegate?.transportManager(self, didEmitDiagnostic: level, message: message, context: context) + private func startBrowsing() { + guard browser == nil, !isPaused else { return } + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: Self.SERVICE_TYPE, domain: nil), + using: Self.makeParameters() + ) + browser.stateUpdateHandler = { [weak self, weak browser] newState in + guard let self = self, let browser = browser, browser === self.browser else { return } + self.browserChanged(newState) + } + browser.browseResultsChangedHandler = { [weak self, weak browser] _, changes in + guard let self = self, let browser = browser, browser === self.browser else { return } + self.advertsChanged(changes) + } + self.browser = browser + browser.start(queue: linkQueue) } -} - -// MARK: - MCSessionDelegate - -extension WifiDirectManager: MCSessionDelegate { - - public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - linkQueue.async { [weak self] in - guard let self = self else { return } - - switch state { - case .connected: - // A callback from a session this manager has since replaced - // (a stop() then start()) has nothing to attach to. - guard self.session === session else { return } - self.peers.connected(peerID) - - case .notConnected: - // Scoped to the current session like the other two callbacks. - // stop() already reported the old session's peers through - // endAll(), and a late disconnect from it would otherwise end - // the same MCPeerID's live link in the new session. - guard self.session === session else { return } - self.peers.ended(peerID) - - case .connecting: - self.emitDiagnostic("debug", "Connecting to peer", context: [ - "peerId": peerID.displayName - ]) - @unknown default: - break + private func stopBrowsing() { + browser?.cancel() + browser = nil + adverts = [:] + } + + private func browserChanged(_ newState: NWBrowser.State) { + switch newState { + case .waiting(let error): + reportNetworkError("Peer-stream discovery waiting", error) + case .failed(let error): + reportNetworkError("Peer-stream discovery failed", error) + stopBrowsing() + let expected = generation + linkQueue.asyncAfter(deadline: .now() + Self.REBUILD_DELAY) { [weak self] in + guard let self = self, self.generation == expected else { return } + self.startBrowsing() } + default: + break } } - public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - linkQueue.async { [weak self] in - guard let self = self, self.session === session else { return } - self.peers.received(data, from: peerID) + private func reportNetworkError(_ message: String, _ error: NWError) { + if case .dns(let code) = error, code == Self.DNS_POLICY_DENIED { + emitDiagnostic("error", "Local network access denied: list _offlineprotocol._tcp in NSBonjourServices and set NSLocalNetworkUsageDescription", context: [ + "error": error.localizedDescription + ]) + } else { + emitDiagnostic("warning", message, context: ["error": error.localizedDescription]) } } - public func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { - // Not used for our message-based protocol - } - - public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { - // Not used for our message-based protocol - } - - public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { - // Not used for our message-based protocol + /// Records each advertised address and dials it. An advert with no `addr` + /// is a device with no identity yet, which has no preamble to send, and + /// ours is skipped by its address. + private func advertsChanged(_ changes: Set) { + for change in changes { + switch change { + case .added(let result), .changed(_, let result, _): + guard let address = Self.advertisedAddress(result), + let local = protocolInstance.localAddress(), address != local else { continue } + adverts[address] = result.endpoint + // The lower address's stream is the one both ends keep, so + // the lower one dials at once and the higher gives it time. + let weAreLower = PeerStreamLinks.newStreamWins( + outbound: true, localAddress: local, peer: address) + scheduleDial(address, after: weAreLower ? 0 : Self.HIGHER_ADDRESS_DIAL_DELAY) + case .removed(let result): + if let address = Self.advertisedAddress(result) { + adverts.removeValue(forKey: address) + } + default: + break + } + } } -} -// MARK: - MCNearbyServiceAdvertiserDelegate + private static func advertisedAddress(_ result: NWBrowser.Result) -> String? { + guard case .bonjour(let txt) = result.metadata, + let address = txt["addr"], !address.isEmpty else { return nil } + return address + } -extension WifiDirectManager: MCNearbyServiceAdvertiserDelegate { - - public func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { - emitDiagnostic("info", "Received invitation from peer", context: [ - "peerId": peerID.displayName - ]) - - // One snapshot for the whole decision, like `foundPeer` below: reading - // `session` again at the accept would let a concurrent stop() hand the - // framework a different value than the budget check saw. A stopped - // transport has nothing to accept into, so it declines instead of - // accepting with a nil session. - guard let session = session else { - invitationHandler(false, nil) - return + private func scheduleDial(_ address: String, after delay: TimeInterval) { + guard !dialing.contains(address), peers.handle(for: address) == nil else { return } + dialing.insert(address) + let expected = generation + linkQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, self.generation == expected else { return } + self.dial(address) } + } - // Enforce connection budget: MCSession limit is 8; stay at 7 to avoid daemon overload. - let currentCount = session.connectedPeers.count - if Self.atConnectionBudgetLimit(connectedCount: currentCount) { - emitDiagnostic("info", "Rejecting invitation: at connection budget limit", context: [ - "connectedCount": currentCount, - "limit": WIFI_DIRECT_MAX_PEERS_SAFE - ]) - invitationHandler(false, nil) + /// Opens a stream toward `address`, unless it went away or is already + /// held (an inbound stream got here first). + private func dial(_ address: String) { + guard !isPaused, let endpoint = adverts[address], peers.handle(for: address) == nil, + streams.count < Self.MAX_STREAMS else { + dialing.remove(address) return } - invitationHandler(true, session) - } - - public func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) { - emitDiagnostic("error", "Failed to start advertising", context: [ - "error": error.localizedDescription - ]) + let stream = Stream( + connection: NWConnection(to: endpoint, using: Self.makeParameters()), + outbound: true, + dialed: address + ) + peers.claim(address, for: stream) + open(stream) + } + + // MARK: - Streams (linkQueue only) + + private func open(_ stream: Stream) { + streams.insert(stream) + stream.connection.stateUpdateHandler = { [weak self, weak stream] newState in + guard let self = self, let stream = stream, self.streams.contains(stream) else { return } + switch newState { + case .ready: + self.peers.connected(stream) + self.receive(on: stream) + case .waiting: + // The peer is not reachable now. Ended rather than left + // waiting, so the redial ladder decides when to try again. + stream.connection.cancel() + case .failed, .cancelled: + self.end(stream) + default: + break + } + } + stream.connection.start(queue: linkQueue) + } + + /// Reads the stream and hands each whole frame to the session, which owns + /// the preamble, the frame rules and every call to the core. A reader + /// refusal ends the stream, and the end reports the loss if the peer was + /// announced. + private func receive(on stream: Stream) { + stream.connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self, weak stream] content, _, isComplete, error in + guard let self = self, let stream = stream, self.streams.contains(stream) else { return } + if let content = content, !content.isEmpty { + for result in stream.reader.append(content) { + switch result { + case .success(let frame): + self.peers.received(frame, from: stream) + case .failure(let refusal): + self.emitDiagnostic("warning", "Peer stream refused", context: ["reason": refusal.reason]) + stream.connection.cancel() + return + } + } + if let address = stream.dialed, self.peers.handle(for: address) == stream { + self.redialDelay.removeValue(forKey: address) + } + } + if isComplete || error != nil { + stream.connection.cancel() + return + } + self.receive(on: stream) + } } -} -// MARK: - MCNearbyServiceBrowserDelegate + /// The stream is over. Reported lost if it held an address, and an + /// outbound one is dialed again, later each time, while its advert stays. + /// A stream refused because the other one for its address won finds the + /// address held and does not redial. + private func end(_ stream: Stream) { + guard streams.remove(stream) != nil else { return } + peers.ended(stream) + stream.writes.reset() + stream.connection.cancel() + guard let address = stream.dialed else { return } + dialing.remove(address) + guard adverts[address] != nil else { return } + let delay = redialDelay[address] ?? Self.REDIAL_INITIAL_DELAY + redialDelay[address] = min(delay * 2, Self.REDIAL_MAX_DELAY) + scheduleDial(address, after: delay) + } -extension WifiDirectManager: MCNearbyServiceBrowserDelegate { - - public func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { - emitDiagnostic("info", "Found peer", context: [ - "peerId": peerID.displayName, - "discoveryInfo": info ?? [:] - ]) - - // One snapshot for the whole decision. This read used to happen three - // separate times and end in `session!`, so a stop() landing between - // the guard and the invite crashed on the force-unwrap — the exact - // race the accessors above exist to close, at the one call site that - // still reached around them. A nil session means the transport is - // down, which is nothing to invite anyone to. - guard let session = session else { return } - - // Don't invite ourselves. Read off the session: the id is minted per - // start(), and the session is the one this browser belongs to. - guard peerID != session.myPeerID else { return } - - // Don't invite if already connected - guard !session.connectedPeers.contains(peerID) else { return } - - // Enforce connection budget: avoid MCSession overflow and connection storms. - guard !Self.atConnectionBudgetLimit(connectedCount: session.connectedPeers.count) else { - return + /// Runs `body` on `linkQueue` and waits, or inline when already there. + /// Inline matters: `deinit` calls `stop()`, and the last reference can be + /// released inside a `linkQueue` block, where a `sync` onto the same + /// queue would trap. + private func onLinkQueueSync(_ body: () -> Void) { + if DispatchQueue.getSpecific(key: Self.linkQueueKey) == true { + body() + } else { + linkQueue.sync(execute: body) } + } - // Record the advertised address before inviting, on the queue the - // connect callback will run on, so the preamble is checked against it. - let claim = info?["addr"] - linkQueue.async { [weak self] in - self?.peers.claim(claim, for: peerID) - } + // MARK: - State Management - browser.invitePeer(peerID, to: session, withContext: nil, timeout: CONNECTION_TIMEOUT) - } - - public func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - emitDiagnostic("info", "Lost peer", context: [ - "peerId": peerID.displayName - ]) + private func updateState(_ newState: TransportState) { + setState(newState) + // Deliberately outside the lock: the delegate is the bridge module, + // and calling into it while holding [stateLock] would put arbitrary + // downstream work, including UniFFI calls, inside this manager's + // critical section. + delegate?.transportManager(self, didChangeState: newState) } - - public func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) { - emitDiagnostic("error", "Failed to start browsing", context: [ - "error": error.localizedDescription - ]) + + // MARK: - Diagnostics + + private func emitDiagnostic(_ level: String, _ message: String, context: [String: Any] = [:]) { + delegate?.transportManager(self, didEmitDiagnostic: level, message: message, context: context) } } @@ -613,7 +720,7 @@ extension WifiDirectManager: PeerStreamHost { do { try protocolInstance.wifiDirectPeerConnected(peerId: address) } catch { - emitDiagnostic("error", "Error announcing Wi-Fi Direct peer", context: [ + emitDiagnostic("error", "Error announcing peer-stream peer", context: [ "error": error.localizedDescription ]) } @@ -625,7 +732,7 @@ extension WifiDirectManager: PeerStreamHost { } catch { // The core refused this body. That is about the message, not the // peer, so the peer stays. - emitDiagnostic("warning", "Wi-Fi Direct body refused by the core", context: [ + emitDiagnostic("warning", "Peer-stream body refused by the core", context: [ "address": address, "error": error.localizedDescription ]) @@ -636,11 +743,11 @@ extension WifiDirectManager: PeerStreamHost { do { try protocolInstance.wifiDirectPeerDisconnected(peerId: address) } catch { - emitDiagnostic("error", "Error reporting Wi-Fi Direct peer lost", context: [ + emitDiagnostic("error", "Error reporting peer-stream peer lost", context: [ "error": error.localizedDescription ]) } - emitDiagnostic("info", "Wi-Fi Direct peer disconnected", context: ["address": address]) + emitDiagnostic("info", "Peer-stream peer disconnected", context: ["address": address]) } func peerStreamDiagnostic(_ level: String, _ message: String, _ context: [String: Any]) { @@ -649,4 +756,3 @@ extension WifiDirectManager: PeerStreamHost { } extension WifiDirectManager: @unchecked Sendable {} - diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index bcf41a38a..6c7706289 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -4842,7 +4842,7 @@ impl OfflineProtocol { // PEER-STREAM TRANSPORT OPERATIONS (the `wifi_direct` slot) // // A byte stream the platform established to one peer: a Wi-Fi Direct - // group socket, a Multipeer session, a TCP connection over a LAN or a + // group socket, a TCP connection over a LAN, over AWDL, or over a // routed mesh. `docs/spec/stream-framing.md` is the contract. The // platform frames (`u32` big-endian length plus body), exchanges the // identity assertion as the first frame in each direction, and announces @@ -16500,7 +16500,7 @@ mod tests { ( "ios/WifiDirectManager.swift", "the resume drain", - "browser?.startBrowsingForPeers() drainAndSendMessages()", + "onLinkQueueSync { startBrowsing() } drainAndSendMessages()", ), // --- an explicit start() means "run" --------------------------- ( @@ -16875,12 +16875,6 @@ mod tests { "private func setState(_ newState: TransportState) { stateLock.lock(); \ defer { stateLock.unlock() } _state = newState }", ), - ( - "the session accessor", - "private var session: MCSession? { get { stateLock.lock(); \ - defer { stateLock.unlock() }; return _session } set { stateLock.lock(); \ - defer { stateLock.unlock() }; _session = newValue } }", - ), ( // The proved peers moved to `PeerStreamLinks`, whose own lock // is held to the same rule by @@ -16906,8 +16900,8 @@ mod tests { let acquisitions = code.matches("stateLock.lock()").count(); assert_eq!( - acquisitions, 6, - "ios/WifiDirectManager.swift: expected the 6 pinned stateLock acquisitions; found \ + acquisitions, 4, + "ios/WifiDirectManager.swift: expected the 4 pinned stateLock acquisitions; found \ {acquisitions}. A new one is not wrong, but it is unreviewed — pin its body above \ so it cannot grow a call into the core unnoticed." ); @@ -17004,12 +16998,13 @@ mod tests { /// Two orderings in the peer-stream managers that no test can run. /// - /// iOS `stop()` forgets the session before it ends every link. The - /// state-change and data callbacks check `self.session === session` on - /// the link queue, so a `.connected` already queued then finds nothing; forgotten after - /// `endAll()`, it created a link in the emptied table that `start()` - /// never clears, and a remote that kept its MCPeerID met a stale refused - /// link on the next session. + /// iOS `stop()` forgets the listener, the browser and every stream before + /// it ends every link. Each Network framework callback checks, on the link + /// queue, that its object is still the manager's, so a `.ready` already + /// queued then finds nothing; forgotten after `endAll()`, a stream that + /// became ready in between was announced into the emptied table, and the + /// core held a link to a stream that was about to be cancelled. The + /// Multipeer manager this replaced lost a remote exactly that way. /// /// Android resets the redial delay on a new connection after a /// disconnect (group owners nearly always share one address, so the @@ -17021,10 +17016,12 @@ mod tests { let swift = rn_source_code_only("ios/WifiDirectManager.swift"); assert!( swift.contains( - "let old = session session = nil onLinkQueueSync { peers.endAll() } \ - old?.disconnect()" + "onLinkQueueSync { generation += 1 \ + let old = (listener: listener, browser: browser, streams: streams) \ + listener = nil browser = nil streams = [] adverts = [:] dialing = [] \ + redialDelay = [:] peers.endAll() old.listener?.cancel()" ), - "ios/WifiDirectManager.swift: stop() must clear the session before endAll()" + "ios/WifiDirectManager.swift: stop() must forget every stream before endAll()" ); let kotlin = rn_source_code_only("android/src/main/java/com/offlineprotocol/WifiDirectManager.kt"); @@ -17160,10 +17157,12 @@ mod tests { // The losing kind of a duplicate announces nothing and // reports nothing: the held stream keeps the address. "if announcement.refused { link.refused = true disconnect(handle)", - // A late disconnect from a replaced session must not end - // the same MCPeerID's link in the new one. - "case .notConnected: guard self.session === session else { return } \ - self.peers.ended(peerID)", + // A late callback from a stream stop() forgot must not + // reach the session: every stream event checks first. + "guard let self = self, let stream = stream, self.streams.contains(stream) \ + else { return } switch newState { case .ready: self.peers.connected(stream)", + "case .failed, .cancelled: self.end(stream)", + "guard streams.remove(stream) != nil else { return } peers.ended(stream)", "if announcement.firstForAddress { host.peerStreamConnected(address) }", "case .deliver(let address, let payload): \ host.peerStreamReceived(address, payload)", @@ -17175,7 +17174,9 @@ mod tests { "return Data(try protocolInstance.identityAssertion(signedData: []))", "switch PeerStreamFraming.unframe(message, \ preamble: link.preamble.awaitingPreamble)", - "private let SERVICE_TYPE = \"offlineprotocol\"", + "static let SERVICE_TYPE = \"_offlineprotocol._tcp\"", + // The chapter's TXT rule: `txtvers=1` first. + "var entries = [\"txtvers=1\"]", ][..], ), ( @@ -17272,23 +17273,38 @@ mod tests { "PeerStreamSession.swift must unframe in exactly one place, received(_:from:)" ); - // The iOS MCPeerID is minted per start(). `PeerStreamSession` keys a - // peer's state by it, and one id for the manager's lifetime let a - // remote's clean stop/start reconnect under a handle that still said - // its preamble was sent: no preamble went out, and the remote refused - // us at its deadline. + // Every inbound byte on iOS goes through the reader, which refuses a + // prefix out of bounds before buffering the body, and then through + // the session, once per frame. A second path to `peers.received` + // would be one that skipped the reader's bound. + let ios_manager = rn_source_code_only("ios/WifiDirectManager.swift"); assert!( - swift.contains("let peerId = MCPeerID(displayName: deviceId) session = MCSession("), - "ios/WifiDirectManager.swift: start() must mint the MCPeerID it builds the session on" + ios_manager.contains( + "for result in stream.reader.append(content) { switch result { \ + case .success(let frame): self.peers.received(frame, from: stream)" + ), + "ios/WifiDirectManager.swift: every received chunk must go through PeerStreamReader" ); + assert_eq!( + ios_manager.matches("peers.received(").count(), + 1, + "ios/WifiDirectManager.swift: expected exactly one call into the session per frame" + ); + // A stream handle is its own identity, never a value that a later + // stream could share: the Multipeer manager keyed its peers by an id + // that survived a restart, and a reconnect then met state that said + // its preamble was already sent. assert!( - !swift.contains("private let peerId: MCPeerID") - && !swift.contains("private var peerId: MCPeerID"), - "ios/WifiDirectManager.swift: the MCPeerID must not outlive one start()" + ios_manager + .contains("static func == (lhs: Stream, rhs: Stream) -> Bool { lhs === rhs }"), + "ios/WifiDirectManager.swift: a Stream must compare by identity" ); + // Our own advert is skipped by the address it carries. assert!( - swift.contains("guard peerID != session.myPeerID else { return }"), - "ios/WifiDirectManager.swift: the browser must skip itself by the session's own id" + ios_manager.contains( + "let local = protocolInstance.localAddress(), address != local else { continue }" + ), + "ios/WifiDirectManager.swift: the browser must skip our own address" ); // A group client redials the owner the group has NOW. On a group diff --git a/examples/react-native-app/INTEGRATION_GUIDE.md b/examples/react-native-app/INTEGRATION_GUIDE.md index 55b833f00..bf914d1af 100644 --- a/examples/react-native-app/INTEGRATION_GUIDE.md +++ b/examples/react-native-app/INTEGRATION_GUIDE.md @@ -90,7 +90,6 @@ Add required permissions to `ios/YourApp/Info.plist`: NSBonjourServices _offlineprotocol._tcp - _offlineprotocol._udp diff --git a/examples/react-native-app/ios/OfflineProtocolExample/Info.plist b/examples/react-native-app/ios/OfflineProtocolExample/Info.plist index 4f45da0d8..5271a2869 100644 --- a/examples/react-native-app/ios/OfflineProtocolExample/Info.plist +++ b/examples/react-native-app/ios/OfflineProtocolExample/Info.plist @@ -40,7 +40,6 @@ NSBonjourServices _offlineprotocol._tcp - _offlineprotocol._udp NSLocalNetworkUsageDescription This app uses local network to discover and communicate with nearby devices From 707b982749d0cdc6477295bff3295ea6fe2c02cf Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Wed, 30 Sep 2026 22:42:58 +0400 Subject: [PATCH 03/16] docs: ADR 0027, and the peer-stream docs for Network framework on iOS 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. --- CHANGELOG.md | 36 ++++-- .../peer_stream_manager.py | 2 +- .../com/offlineprotocol/PeerStreamFraming.kt | 2 +- .../react-native/ios/PeerStreamFraming.swift | 2 +- .../react-native/ios/WifiDirectManager.swift | 4 +- .../ios/tests/PeerStreamFramingTests.swift | 2 +- bindings/react-native/src/index.ts | 2 +- crates/offline-protocol-uniffi/src/lib.rs | 2 +- ...ios-peer-streams-ride-network-framework.md | 117 ++++++++++++++++++ docs/adr/README.md | 1 + docs/api-reference.md | 2 +- docs/bridges/README.md | 4 +- docs/bridges/python.md | 6 +- docs/bridges/swift.md | 79 ++++++------ docs/configuration.md | 4 +- docs/ios-integration.md | 27 ++-- docs/security/threat-model.md | 22 +++- docs/spec/stream-framing.md | 45 +++---- docs/telemetry.md | 4 +- docs/transport-architecture.md | 13 +- 20 files changed, 272 insertions(+), 104 deletions(-) create mode 100644 docs/adr/0027-ios-peer-streams-ride-network-framework.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb49de4c..5ecfbab8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -501,15 +501,16 @@ archived by series under [docs/changelog/](docs/changelog/); see the core, whatever `appId` the app had configured. They now stamp the configured id. - **The mobile peer-stream managers carry traffic.** Android's Wi-Fi Direct - manager and iOS's Multipeer manager used to drop every inbound frame and + manager and iOS's peer-stream manager used to drop every inbound frame and announce no peer, because nothing on the wire said who a peer was. Both now exchange the identity preamble from [the stream chapter](docs/spec/stream-framing.md): each side sends its assertion first, checks the peer's with `verifyIdentityAssertion`, and announces the peer only under the address it proved. A peer that sends anything else first, or nothing for ten seconds, is disconnected unannounced. - One peer is announced per address, and a newer connection for the same - address replaces the older one without a loss event. A peer is reported + One peer is announced per address, and of two connections for the same + address one is closed without a loss event (on Android the newer is kept, + on iOS the one the lower address opened; see Changed). A peer is reported lost exactly once, and no message is delivered after that report. - **Android Wi-Fi Direct framing faults.** The reader refused a message of exactly 1 MiB, and after refusing a length it read the skipped body as the @@ -569,14 +570,27 @@ archived by series under [docs/changelog/](docs/changelog/); see the short throttle while the user edits, at most about twice a second, and once more when editing stops. -- **iOS: the Multipeer service type is `offlineprotocol`.** It was - `offline-proto`. An app that enables the `wifiDirect` transport on iOS must - list both `_offlineprotocol._tcp` and `_offlineprotocol._udp` under - `NSBonjourServices` in its `Info.plist`, or iOS blocks discovery with no - error. Its discovery info now carries `txtvers` and `addr` instead of the - app's `profile`. An iPhone on this release and one on an earlier release do - not see each other over Multipeer. Neither carried traffic before, so no - working path is lost. +- **iOS: the peer-stream slot runs on Network framework, not + MultipeerConnectivity.** The `wifiDirect` transport on iOS now opens TCP + streams with `NWListener`, `NWBrowser` and `NWConnection`, over the local + network or, with no shared network, over AWDL, so two iPhones still reach + each other with no access point. It advertises and browses + `_offlineprotocol._tcp` with `txtvers=1` and `addr`, the record the Python + `PeerStreamManager` uses, so an iPhone and a host on one LAN now find and + talk to each other. 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 `_udp` entry can go), and + `NSLocalNetworkUsageDescription` is still required. A denied local-network + permission is now reported as an `error` diagnostic instead of failing + silently. The seven-peer cap Multipeer imposed is gone; at most sixteen + streams are open. The hop is plain TCP, as on Android and Python, where + Multipeer encrypted it; payloads are sealed either way. Both ends of a pair + now dial, and of two streams for one address both keep the one the lower + address opened, the Python manager's rule + ([ADR 0027](docs/adr/0027-ios-peer-streams-ride-network-framework.md)). + The podspec links `Network` in place of `MultipeerConnectivity`. This + replaces the unreleased Multipeer service-type change, which never + shipped. - **Python: `InternetManager` requires `app_id`.** It is now a keyword-only argument with no default, because the default was the fixed id above. diff --git a/bindings/python/offline_protocol_sdk/peer_stream_manager.py b/bindings/python/offline_protocol_sdk/peer_stream_manager.py index e7db4f413..3f7fd2640 100644 --- a/bindings/python/offline_protocol_sdk/peer_stream_manager.py +++ b/bindings/python/offline_protocol_sdk/peer_stream_manager.py @@ -1,7 +1,7 @@ """Peer-stream transport manager: TCP streams behind the ``wifi_direct`` slot. A peer stream is a byte stream established to exactly one other device. On a -phone that is a Wi-Fi Direct group socket or a Multipeer session; on a host +phone that is a Wi-Fi Direct group socket or a TCP stream over AWDL; on a host it is a TCP connection over a LAN or a routed mesh. To the engine they are one transport, registered in the slot the FFI names ``wifi_direct`` for historical reasons, and this manager is the host's implementation of the diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/PeerStreamFraming.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/PeerStreamFraming.kt index 9640ccd15..bdef08f3e 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/PeerStreamFraming.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/PeerStreamFraming.kt @@ -152,7 +152,7 @@ class PeerStreamPreamble( * * Policy: the newer stream supersedes the older. On a phone the duplicate is * almost always the same peer reconnecting past a stream that went half-open - * (a group client that re-joined, a Multipeer peer that restarted), and + * (a group client that re-joined), and * refusing the newer one would leave that peer unreachable until the stale * stream's socket noticed. The cost, recorded in R16, is that a replayer can * choose when a real stream ends; it cannot use the stream it gets. diff --git a/bindings/react-native/ios/PeerStreamFraming.swift b/bindings/react-native/ios/PeerStreamFraming.swift index 39676a029..e6cc9e95a 100644 --- a/bindings/react-native/ios/PeerStreamFraming.swift +++ b/bindings/react-native/ios/PeerStreamFraming.swift @@ -198,7 +198,7 @@ final class PeerStreamPreamble { /// same LAN, so without a shared rule each end would keep the stream the other /// closes, and the pair would reconnect forever. It is the Python manager's /// `_new_stream_wins`, and `ios_and_python_peer_streams_keep_the_same_stream` -/// pins the two copies together (ADR 0026). "Newer" among winners is what +/// pins the two copies together (ADR 0027). "Newer" among winners is what /// lets the lower address reconnect past its own half-open stream; the higher /// address's reconnect waits for keepalive to end the stale one. The cost, /// recorded in R16, is that a replayer can end a real stream when its copy is diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 73313d09b..adab63538 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -5,7 +5,7 @@ // The iOS peer-stream transport: TCP streams over Network framework, found // through DNS-SD, on the infrastructure network or over AWDL (Apple's // peer-to-peer Wi-Fi) when there is none. The name is the engine slot's, not -// the radio's: iOS has no Wi-Fi Direct API (ADR 0026). +// the radio's: iOS has no Wi-Fi Direct API (ADR 0027). // import Foundation @@ -212,7 +212,7 @@ public class WifiDirectManager: NSObject, TransportManager { /// TCP with keepalive, over AWDL as well as the infrastructure network. /// The one place a carrier is chosen: Wi-Fi Aware, when it comes, starts - /// here (ADR 0026). Keepalive is the Python manager's: a stream whose peer + /// here (ADR 0027). Keepalive is the Python manager's: a stream whose peer /// died idle ends within about thirty seconds, which is what lets the /// losing kind of reconnect through once the stale winner is gone. private static func makeParameters() -> NWParameters { diff --git a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift index 63c49999c..88f5b1dfa 100644 --- a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift @@ -283,7 +283,7 @@ final class PeerStreamFramingTests: XCTestCase { } func testTheWinningKindIsWhateverTheLowerAddressOpened() { - // Python's `_new_stream_wins`, case for case (ADR 0026). + // Python's `_new_stream_wins`, case for case (ADR 0027). XCTAssertTrue(PeerStreamLinks.newStreamWins(outbound: true, localAddress: me, peer: a)) XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: false, localAddress: me, peer: a)) XCTAssertFalse(PeerStreamLinks.newStreamWins(outbound: true, localAddress: a, peer: me)) diff --git a/bindings/react-native/src/index.ts b/bindings/react-native/src/index.ts index 0980552c5..39a45ddca 100644 --- a/bindings/react-native/src/index.ts +++ b/bindings/react-native/src/index.ts @@ -2437,7 +2437,7 @@ export class OfflineProtocol { // PEER-STREAM TRANSPORT METHODS (the `wifiDirect` slot, Low-Level) // // A byte stream the platform established to exactly one peer: a Wi-Fi - // Direct group socket, a Multipeer session, a TCP connection over a LAN or + // Direct group socket, a TCP connection over a LAN, over AWDL, or over // a routed mesh. To the engine they are one transport, and the slot's name // is historical. The contract is docs/spec/stream-framing.md: every frame // is a u32 big-endian length then the body (at most 1 MiB); the first frame diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 6c7706289..98890abd2 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17044,7 +17044,7 @@ mod tests { /// hand-mirrored policy (docs/bridges C5): the stream the lower address /// opened wins. If the two copies drift, an iPhone and a Python host each /// keep the stream the other closes, and the pair reconnects forever, - /// with no error on either side (ADR 0026). Pinned as the two lines each + /// with no error on either side (ADR 0027). Pinned as the two lines each /// rule is made of. Android keeps the newer stream instead, because a /// Wi-Fi Direct group has one dialer. #[test] diff --git a/docs/adr/0027-ios-peer-streams-ride-network-framework.md b/docs/adr/0027-ios-peer-streams-ride-network-framework.md new file mode 100644 index 000000000..a53997dc8 --- /dev/null +++ b/docs/adr/0027-ios-peer-streams-ride-network-framework.md @@ -0,0 +1,117 @@ +# 0027. iOS peer streams ride Network framework and keep the lower-opened stream + +**Status:** Accepted + +## Context + +The engine has one slot for a platform-established stream to one peer, the +one the FFI calls `wifi_direct` ([stream framing](../spec/stream-framing.md)). +On iOS it was filled by MultipeerConnectivity, and that was the one carrier +that did not implement the chapter as written: + +- **It was message-oriented.** The chapter describes a byte stream with a + length before every body. Multipeer delivers whole messages, so the iOS + framing carried a special case in which each message had to be exactly one + frame. +- **It spoke to nothing else.** A Multipeer peer is another Multipeer app. It + cannot reach the Python `PeerStreamManager`, which advertises and dials + `_offlineprotocol._tcp` on a LAN. +- **It squatted the service type.** Multipeer publishes `offlineprotocol` as + `_offlineprotocol._tcp` with its own protocol behind it. A Python host + browsing the same LAN finds that record, with `txtvers` and `addr` in it, + and dials a socket that does not speak the chapter. +- **It capped a session at eight peers.** The manager kept to seven. + +Network framework does what the chapter describes. `NWListener` and +`NWBrowser` publish and find `_offlineprotocol._tcp` with a TXT record, and +`NWConnection` is a TCP stream. With `includePeerToPeer` all three also run +over AWDL, the peer-to-peer Wi-Fi that Multipeer itself runs on, so two +iPhones with no access point still reach each other. It is also the +framework Apple's Wi-Fi Aware support is built on. + +Moving there changes one property the mobile policy leaned on: who dials. +In a Multipeer session the browser invites, and the chapter's mobile rule for +two streams to one address, "the newer supersedes", was written for carriers +where both ends do not dial. Over Network framework both ends browse, and both +can dial. + +## Decision + +1. **The iOS peer-stream manager is Network framework, TCP, with + `includePeerToPeer`.** Discovery is DNS-SD `_offlineprotocol._tcp` with a + TXT record whose first entry is `txtvers=1` and which carries `addr`, the + same record the Python manager publishes. `NSBonjourServices` needs only + `_offlineprotocol._tcp`. +2. **Of two streams for one address, both ends keep the one the lower + address opened, and the newer of two such.** This is the Python manager's + rule. Addresses compare by their UTF-8 bytes. A Rust guard pins the Swift + and Python copies together + (`ios_and_python_peer_streams_keep_the_same_stream`). +3. **Both ends dial, the higher address after five seconds.** The lower + address's stream is the one that will be kept, so it goes first, and the + common case opens one stream per pair. The higher address still dials, + which covers discovery that only one side sees. +4. **The link is plain TCP.** The chapter already says link encryption forms + no part of the trust argument, and Android and Python carry none. Every + message inside a frame is sealed or signed at the layer that owns it. +5. **The change is a clean cut.** A Multipeer build and a Network-framework + build do not see each other. The SDK is before 1.0 and the slot started + carrying traffic recently, so running both carriers for one release was + not worth doubling the manager. +6. **Wi-Fi Aware is not part of this.** The manager chooses its carrier in + one function, `makeParameters()`, which is where that work would start. + +### Why not "the lower address dials, and the newer stream wins" + +That option keeps the old rule and gates the dial instead: dial only a peer +whose advertised `addr` is higher than ours. Between two iPhones it holds one +dialer per pair and needs no change to `PeerStreamLinks`. It was rejected +because it breaks the migration's main gain: + +- **Against a Python host it reconnects forever.** Python dials everything it + discovers and keeps the stream the lower address opened. When the iPhone is + the lower address, both ends dial, and the iPhone keeps whichever stream + proved last. About half the time each end keeps the stream the other + closes, both streams close, and the pair tries again. No error is raised + on either side. This is the failure the Python manager's rule was written + to prevent. +- **It needs discovery in both directions.** If only the higher address sees + the other, nobody dials. +- **It leaves a replayed preamble more room.** Under "newer wins", every + replayed inbound stream supersedes the real one + ([R16](../security/threat-model.md#r16-the-identity-assertion-is-static-and-replayable-on-every-carrier)). + Under the lower-opened rule, a receiver that is the lower address refuses + it. + +Neither option handles a half-open stream better than the other. Both need +TCP keepalive to end a stream whose peer died while idle, so keepalive is not +a cost of the choice. The manager uses the Python manager's timers: 15 +seconds idle, 5 between probes, 3 probes. + +## Consequences + +- An iPhone and a Python host on one LAN find each other and keep one + stream. Android does not change: its Wi-Fi Direct group has one dialer and + keeps "newer supersedes", which the chapter allows as local policy. iOS and + Android still do not talk directly. +- The stream budget is 16 open streams, as on Android, instead of seven. +- The first contact between two iPhones may open two streams if the lower + one's dial takes longer than five seconds. One closes without a report, + which the chapter's one-stream-per-address rule already covers. +- The higher address's reconnect past a half-open stream waits for keepalive, + up to about thirty seconds, because the lower side still holds a stream it + believes live. The lower address's reconnect supersedes its own stale + stream at once. +- On a shared network a device on the path can read what a frame's envelope + leaves in clear, which Multipeer's session encryption used to hide. + Payloads are sealed either way. The threat model records it with the other + carriers. +- A local-network denial is reported as a diagnostic. Multipeer failed + silently there. + +## What would undo this + +Wi-Fi Aware on iOS 26 changes the carrier, not this decision: it runs through +the same `NWListener`, `NWBrowser` and `NWConnection`, and would reuse the +rule. Going back to "newer supersedes" on iOS would need every carrier that +reaches an iPhone to have a single dialer, and a Python host does not. diff --git a/docs/adr/README.md b/docs/adr/README.md index 38b658e58..1d150eeda 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,6 +37,7 @@ silently undo it. Decisions that follow from the obvious default do not need one | [0024](0024-the-sdk-ships-its-own-tls-stack.md) | The SDK ships its own TLS stack for telemetry | Accepted | | [0025](0025-native-packages-are-assembled-in-place.md) | Native packages are assembled from the bridge sources in place | Accepted | | [0026](0026-the-backbone-is-a-gateway-property.md) | The backbone is a gateway property | Accepted | +| [0027](0027-ios-peer-streams-ride-network-framework.md) | iOS peer streams ride Network framework and keep the lower-opened stream | Accepted | ## Format diff --git a/docs/api-reference.md b/docs/api-reference.md index 8672ef48e..75a2329b1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -25,7 +25,7 @@ Available transport mechanisms. pub enum TransportType { Internet, // Online connectivity (unlimited range) BLE, // Bluetooth Low Energy mesh (50-100m) - WiFiDirect, // Peer-stream slot: Wi-Fi Direct on Android, Multipeer on iOS, a socket on hosts (spec/stream-framing.md) + WiFiDirect, // Peer-stream slot: Wi-Fi Direct on Android, a TCP stream over a LAN or AWDL on iOS, a socket on hosts (spec/stream-framing.md) Reticulum, // Reticulum mesh (LoRa, TCP, UDP, serial, I2P) Nostr, // Nostr relay (censorship-resistant WebSocket relays) } diff --git a/docs/bridges/README.md b/docs/bridges/README.md index 251920783..327a22270 100644 --- a/docs/bridges/README.md +++ b/docs/bridges/README.md @@ -283,7 +283,7 @@ the inclusive 1 MiB ceiling and the 96-byte preamble floor of `peer_stream_manager.py`, beside the transport crate's constants. Every language pins them the per-language way, as literals in a suite CI executes, and each suite also replays the chapter's vector file, so a drift on one side -fails that side's tests. The Multipeer service type `offlineprotocol` is the +fails that side's tests. The iOS service type `_offlineprotocol._tcp` is the one piece pinned by a Rust guard instead, `react_native_wifi_direct_announces_only_proved_addresses`, because the iOS manager that holds it is excluded from the SwiftPM harness. A drifted @@ -609,7 +609,7 @@ found by the first application to update. | Binding | Owes | |---------|------| -| Swift | The manual Objective-C bridge kept in step with every `@objc` method; secure storage backed by Keychain; a live-instance check before emitting; the telemetry session boundary inside a background task (C12); a Multipeer manager that announces a peer only under the address its preamble proved, one per address (S8) | +| Swift | The manual Objective-C bridge kept in step with every `@objc` method; secure storage backed by Keychain; a live-instance check before emitting; the telemetry session boundary inside a background task (C12); a Network-framework peer-stream manager that announces a peer only under the address its preamble proved, one per address (S8) | | Kotlin | Secure storage backed by Keystore; no blocking work on the main looper; awareness that platform callbacks arrive on binder threads; the telemetry session boundary from an `Application.ActivityLifecycleCallbacks` watcher, never `onHostPause` (C12); a Wi-Fi Direct manager that announces a peer only under the address its preamble proved, one per address (K8) | | Python | Nothing platform-specific; it is the thinnest binding and therefore the best place to smoke-test an ABI change; a re-entrant lock on the generated callback handle map, installed at import, because the collector can free a core object inside a callback lookup and the core's drop then asks for that lock again (P10); the host platform for telemetry from `platform`; a BLE peripheral that serves the address and the core-built identity assertion, and a central that verifies before it announces (P8); a peer-stream manager that announces a host only under the address its preamble proved, and keeps one announced stream per address (P9); a gateway-daemon client that announces a session only once the gateway bound it to this device's address, and settles a frame only on the gateway's verdict, never on the write (P11) | | TypeScript | Config normalization, event typing kept in step with the core, no assumption that a native method exists in an older binary, and no telemetry lifecycle code of its own | diff --git a/docs/bridges/python.md b/docs/bridges/python.md index 8c5a7da5b..30b1ff787 100644 --- a/docs/bridges/python.md +++ b/docs/bridges/python.md @@ -173,7 +173,11 @@ kept (two hosts that each list the other open toward each other at once, and without a shared rule each would keep what the other discards, forever), and between two of the winning kind the newer supersedes the older (the lower address reconnecting must not be blocked by its own half-open stream). Either -way the core sees one announcement and one loss per address. +way the core sees one announcement and one loss per address. The iOS manager +keeps the same stream by the same rule, because an iPhone and a host on one +LAN both dial; `ios_and_python_peer_streams_keep_the_same_stream` pins the +two copies together +([ADR 0027](../adr/0027-ios-peer-streams-ride-network-framework.md)). The tie-break gives the higher address no such way past a stale stream: its reconnect is the losing kind for as long as the lower side holds a stream it diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 3726199b9..beeebaea9 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -203,41 +203,50 @@ exemption list. It is pinned against literals in `RelayAnswerPrefixesTests.swift See [C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language). -## S8. A Multipeer peer's verdict is its preamble, not its connect - -`WifiDirectManager` fills the peer-stream slot with MultipeerConnectivity, -framed as [the chapter](../spec/stream-framing.md) specifies. A connected -peer is announced to the core only under the address -`verifyIdentityAssertion` derived from its first message, and only then: -never on connect, never under `MCPeerID.displayName` (the remote's profile, -commonly a shared constant), and never from the `addr` in its discovery info, -which is only a claim the preamble must match. - -The rules live in `PeerStreamSession`, Foundation-only and generic over the -peer handle, and the manager forwards every per-peer event to it on one serial -queue. That queue is the invariant, for two reasons. A peer's first message -can arrive before this side's connect callback, and a message must never be -delivered after the peer's loss report, because the core re-adds a neighbour -on any inbound body. The manager used to hop state changes onto main and -messages onto another queue, which allowed both. - -Local policy differs from Python's P9 on purpose. When a second peer proves -an address already announced, the newer supersedes the older, which is -disconnected with no loss report: Multipeer peers do not both dial, and the -duplicate is almost always the same device back under a new `MCPeerID`. The -manager mints its `MCPeerID` in every `start()` so that holds for a clean -stop and start too: the session's per-peer state is keyed by that id, and a -reused one could meet a new connect with state that says our preamble was -already sent. -There is no write queue or deadline here, because the session queues and -retries its own reliable sends. Each message is one whole frame, and one -whose prefix disagrees with its length is refused, since there is no stream -position to resynchronise on. - -`PeerStreamSessionTests` drives it with string handles, a fake session and a -manual clock, and `PeerStreamFramingTests` replays the chapter's vectors. The -manager's session handling (advertise, browse, invite) is not covered in CI -(C9). +## S8. A peer stream's verdict is its preamble, not its connect + +`WifiDirectManager` fills the peer-stream slot with TCP streams over Network +framework, framed as [the chapter](../spec/stream-framing.md) specifies, and +found through DNS-SD `_offlineprotocol._tcp` on the network or over AWDL +([ADR 0027](../adr/0027-ios-peer-streams-ride-network-framework.md)). A +connected peer is announced to the core only under the address +`verifyIdentityAssertion` derived from its first frame, and only then: never +on connect, and never from the `addr` in its TXT record, which is only a +claim the preamble must match. + +The rules live in two Foundation-only pieces. `PeerStreamReader` cuts the +stream into whole frames and refuses a prefix out of bounds before it +buffers a byte of the body. `PeerStreamSession`, generic over the stream +handle, owns the preamble, the frame rules and every call to the core. The +manager starts its listener, its browser and every connection on one serial +queue, and forwards every per-stream event from there. That queue is the +invariant, for two reasons: a stream's first frame must not overtake its +connect, and a frame must never be delivered after the peer's loss report, +because the core re-adds a neighbour on any inbound body. Every callback +checks that its listener, browser or stream is still the manager's, and +`stop()` forgets all three before it reports every peer lost. + +Both ends of a pair browse, so both can dial. Of two streams for one +address, the one the lower address opened is kept, and the newer of two +such, the Python manager's rule, so an iPhone and a host agree on the same +stream ([P9](python.md#p9-a-socket-clients-verdict-is-the-preamble-not-the-connect)). The lower address dials at once, and the +higher waits five seconds for the lower one's stream to arrive. A refused +duplicate closes with no loss report. A stream handle is a new object per +connection, never an id a later stream could share: the Multipeer manager +this replaced keyed its peers by an id that survived a restart, and a +reconnect met state that said its preamble was already sent. + +Writes follow Android's bounds: a body is dropped (the core retries it) when +more than 4 MiB is queued toward a peer whose oldest write has been +outstanding for two seconds, and a write outstanding for thirty seconds ends +the stream. Every stream carries TCP keepalive with the Python manager's +timers. At most sixteen streams are open. A local-network denial is reported +as a diagnostic naming `NSBonjourServices`. + +`PeerStreamSessionTests` drives the session with string handles, a fake +carrier and a manual clock, `PeerStreamReaderTests` the reader with every +chunking, and `PeerStreamFramingTests` replays the chapter's vectors. The +manager's own handling (advertise, browse, dial) is not covered in CI (C9). ## S9. Every object is built for the pod's deployment target diff --git a/docs/configuration.md b/docs/configuration.md index e556ac681..e2e0025fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -262,7 +262,7 @@ macOS, Linux, and Windows can use the Python binding's snake_case | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `transports.ble.enabled` | boolean | true | Enable BLE mesh | -| `transports.wifiDirect.enabled` | boolean | false | Enable the [peer-stream](spec/stream-framing.md) slot: Wi-Fi Direct on Android, Multipeer on iOS (which needs both `_offlineprotocol` entries in `NSBonjourServices`, see the [iOS guide](ios-integration.md#platform-limitations)) | +| `transports.wifiDirect.enabled` | boolean | false | Enable the [peer-stream](spec/stream-framing.md) slot: Wi-Fi Direct on Android, a TCP stream over a LAN or AWDL on iOS (which needs `_offlineprotocol._tcp` in `NSBonjourServices`, see the [iOS guide](ios-integration.md#platform-limitations)) | | `transports.internet.enabled` | boolean | true | Enable Internet | | `transports.reticulum.enabled` | boolean | false | Enable Reticulum mesh (requires a gateway daemon speaking [contract v1](spec/gateway-contract.md#gateway-daemon-contract-v1)) | | `transports.reticulum.daemonAddress` | string | `localhost:4242` | Host and port of the gateway daemon | @@ -920,7 +920,7 @@ Two consequences worth knowing: ### iOS -**Available Transports**: Internet, BLE, Reticulum, Nostr, and the peer-stream slot through MultipeerConnectivity (iOS has no Wi-Fi Direct API) +**Available Transports**: Internet, BLE, Reticulum, Nostr, and the peer-stream slot through Network framework (iOS has no Wi-Fi Direct API) **Permissions Required**: - `NSBluetoothAlwaysUsageDescription` diff --git a/docs/ios-integration.md b/docs/ios-integration.md index f2b0e53a4..c99565ff7 100644 --- a/docs/ios-integration.md +++ b/docs/ios-integration.md @@ -106,7 +106,7 @@ final class MeshController { appId: "my-ios-app", profile: "user123", bleEnabled: true, - wifiDirectEnabled: false, // the peer-stream slot; Multipeer on iOS + wifiDirectEnabled: false, // the peer-stream slot; TCP over a LAN or AWDL on iOS internetEnabled: true, reticulumEnabled: false, nostrEnabled: false, @@ -423,27 +423,32 @@ benign reading. ## Platform Limitations iOS has no Wi-Fi Direct API. The `wifiDirect` transport slot is the -[peer-stream](spec/stream-framing.md) slot, and on iOS it is filled by -MultipeerConnectivity. Each peer proves its address with the identity -preamble before anything it sends reaches the protocol, and a peer that does -not prove one is disconnected. To enable it, list both Multipeer service -types in `Info.plist`, or iOS local-network privacy blocks discovery with no -error: +[peer-stream](spec/stream-framing.md) slot, and on iOS it is filled by TCP +streams over Network framework, on the local network or, with no shared +network, over AWDL (Apple's peer-to-peer Wi-Fi). Each peer proves its address +with the identity preamble before anything it sends reaches the protocol, and +a peer that does not prove one is disconnected. To enable it, list the +service type in `Info.plist` and describe the local-network use, or iOS +local-network privacy blocks discovery; the SDK reports a denial as an +`error` diagnostic: ```xml NSBonjourServices _offlineprotocol._tcp - _offlineprotocol._udp +NSLocalNetworkUsageDescription +Finds and talks to nearby devices running this app ``` -Multipeer peers are other iOS devices only; it does not interoperate with -Android's Wi-Fi Direct. Available transports: +The slot reaches other iPhones and, on a shared network, hosts running the +Python binding's `PeerStreamManager`. It does not interoperate with +Android's Wi-Fi Direct, and an SDK from 0.27 or earlier, which used +MultipeerConnectivity, does not see it. Available transports: - Bluetooth Low Energy - Internet - Reticulum and Nostr -- The peer-stream slot through Multipeer +- The peer-stream slot through Network framework ## Performance diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 2781a91bf..d93b39ec3 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -133,6 +133,16 @@ design: | Data plane (`__MLS_ENC__`, `__GROUP_MSG__`) | Authenticated **later**, by MLS decryption plus the credential comparison | | Relay answers (six prefixes) | **Not authenticated by this protocol at all** | +No peer-stream carrier encrypts its hop: the Android and iOS managers and the +Python host all speak plain TCP, which the +[stream chapter](../spec/stream-framing.md) allows because the hop forms no +part of the trust argument. A device on the path is A1 for those frames. It +reads what an envelope leaves in clear, such as the application id +([R17](#r17-the-application-id-on-every-frame-is-cleartext-and-unsigned)), +and never a payload. The iOS manager's hop was encrypted while it used +MultipeerConnectivity +([ADR 0027](../adr/0027-ios-peer-streams-ride-network-framework.md)). + ### Boundary 5: end to end **Enforced by:** MLS (RFC 9420), plus the application-side leaf identity binding @@ -700,13 +710,15 @@ the stream chapter's one-stream-per-address rule it gains one more thing: its close is reported as the peer's loss, which evicts the real peer's link until it reconnects. That is why the rule is normative rather than policy. Which stream the rule keeps is policy, and each choice leaves the replayer something. -The mobile managers keep the newer stream, so a replayer chooses when a real +The Android manager keeps the newer stream, so a replayer chooses when a real stream ends: its copy supersedes the real one, the real peer reconnects past it, and each round costs both sides a signature check, while the core sees no -loss because the address never stopped being held. The Python manager keeps -the stream opened by the lower address, which a replayer cannot use to evict -anyone but which lets a stale stream block a reconnect until keepalive -notices. A receiver MUST NOT read a verified +loss because the address never stopped being held. The Python and iOS +managers keep the stream opened by the lower address, and the newer of two +such, so the same replay works only against a receiver whose address is +higher than the peer it copies; a lower receiver refuses the copy. The price +is that a stale stream can block the higher address's reconnect until +keepalive notices. A receiver MUST NOT read a verified assertion as evidence that the peer is live, recent, or the only holder of the address. Two things that look like they would close the gap do not, and the stream chapter says so normatively: link encryption (TLS, a framework's diff --git a/docs/spec/stream-framing.md b/docs/spec/stream-framing.md index 4802aad5a..b065e1f8c 100644 --- a/docs/spec/stream-framing.md +++ b/docs/spec/stream-framing.md @@ -3,8 +3,9 @@ ## What this chapter is for A peer stream is a byte stream the platform established to exactly one other -device: a Wi-Fi Direct group socket on Android, a Multipeer session on iOS, a -TCP connection over a LAN or over a routed mesh on a host. To the protocol +device: a Wi-Fi Direct group socket on Android, a TCP connection over a LAN +or over AWDL (Apple's peer-to-peer Wi-Fi) on iOS, a TCP connection over a LAN +or over a routed mesh on a host. To the protocol engine these are one transport, registered in the slot the FFI names `wifi_direct` for historical reasons, and this chapter is what makes them one: it specifies the only two things a stream does not get from its platform for @@ -150,7 +151,7 @@ one, read off the prefix instead of measured after the fact. The prefix is big-endian because the one shipped implementation of this framing already was: Android's `DataOutputStream.writeInt` writes network order, and the Android manager was written against it. A carrier that is -already message-oriented, such as a Multipeer session, delivers whole messages +already message-oriented delivers whole messages and needs no prefix to find a boundary; it still wraps each message as one frame, so that one receive path serves every carrier and the ceiling is read off the same four bytes everywhere, and a message whose prefix disagrees @@ -220,15 +221,18 @@ The bounds this gives a receiver are the ones that matter. Memory per stream is at most one body in flight, `DEFAULT_MAX_MESSAGE_SIZE + 4` bytes, because a frame is read whole before the next prefix. The number of streams a receiver accepts, and the preamble deadline, are local policy, and a conforming -implementation chooses its own. The mobile managers use ten seconds and, on -Android, sixteen open sockets; the Python manager's choices are in its bridge -rules. Which of two streams for one address to keep is policy too, and the -implementations differ for a reason: the Python manager keeps the stream -opened by the lower address, because two hosts that each list the other dial -at once and must agree without talking, while the mobile managers keep the -newer, because on a phone the duplicate is almost always the same peer -reconnecting past a half-open stream, and neither Wi-Fi Direct nor Multipeer -has both ends dial. +implementation chooses its own. The mobile managers use ten seconds and +sixteen open streams; the Python manager's choices are in its bridge rules. +Which of two streams for one address to keep is policy too, and the +implementations differ for a reason. The Python and iOS managers keep the +stream opened by the lower address, and the newer of two such, because both +ends of a pair dial and must agree without talking: a host dials every peer +it lists or discovers, and an iPhone dials every peer it discovers +([ADR 0027](../adr/0027-ios-peer-streams-ride-network-framework.md)). The two +must compute the rule alike, or an iPhone and a host each keep the stream the +other closes and reconnect forever. The Android manager keeps the newer, +because a Wi-Fi Direct group has one dialer, and on a phone the duplicate is +almost always the same peer reconnecting past a half-open stream. ## Finding a peer on a LAN @@ -240,14 +244,13 @@ allows) and a TXT record whose first entry is `txtvers=1` and which carries `addr=`, the advertiser's canonical address. A framework that publishes DNS-SD on the implementation's behalf is bound by -the same rule. A Multipeer advertiser MUST use the service type -`offlineprotocol`, which the framework publishes as `_offlineprotocol._tcp` -and which fits its fifteen-character limit, and SHOULD carry `addr` in the -discovery dictionary it advertises. Multipeer publishes the type over both -TCP and UDP, so an app's `NSBonjourServices` must list -`_offlineprotocol._tcp` and `_offlineprotocol._udp`, or iOS local-network -privacy blocks discovery without an error. The iOS manager once advertised -`offline-proto`, which no documented entry named. +the same rule, and MUST NOT publish `_offlineprotocol._tcp` for a service +that does not speak this chapter: a browser that finds the record dials it. +The iOS manager publishes the record itself through Network framework, so an +app's `NSBonjourServices` must list `_offlineprotocol._tcp`, or iOS +local-network privacy blocks discovery. The iOS manager once used +MultipeerConnectivity, which published the same type in front of its own +protocol, so a host on the same LAN found an iPhone it could not speak to. The `addr` entry is a hint the preamble proves. It tells a browser which device it is about to connect to, so the derived address of the preamble can @@ -281,7 +284,7 @@ is where a message body is handed upward attributed to it, `wifi_direct_get_next_message()` is where outbound bodies are drained, addressed by `recipient_id`, and `wifi_direct_peer_disconnected(peer_id)` reports the end of an announced stream. The name is debt, recorded rather -than paid: a Wi-Fi Direct group socket, a Multipeer session, a LAN socket +than paid: a Wi-Fi Direct group socket, an AWDL stream, a LAN socket and a routed-mesh socket are the same thing to the engine, a stream to one peer whose identity the platform proved, and renaming the slot is a separate breaking change with an alias period. diff --git a/docs/telemetry.md b/docs/telemetry.md index 61cf86faa..56b7b45b1 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -101,8 +101,8 @@ exactly these `data` fields, and nothing else: | `mesh_session_summary` | `session_duration_s`, `routing_switches`, `routing_escalations`, `escalation_reasons` (six buckets), `mls_session_ready_latency_p50_ms` (absent when no handshake paired), `mls_encryption_used_count` | Computed on the device at each session boundary | `transport_time_ms.wifi_direct` is the dwell time of the peer-stream slot on -every platform, not only Wi-Fi Direct: a Multipeer session on iOS and a LAN or -routed-mesh stream on a host are the same transport to the engine +every platform, not only Wi-Fi Direct: a LAN or AWDL stream on iOS and a LAN +or routed-mesh stream on a host are the same transport to the engine ([peer-stream framing](spec/stream-framing.md)), so their time lands under the slot's historical key. Renaming the key is part of renaming the slot, a separate breaking change. diff --git a/docs/transport-architecture.md b/docs/transport-architecture.md index 8e7ff45ee..9440a5152 100644 --- a/docs/transport-architecture.md +++ b/docs/transport-architecture.md @@ -148,7 +148,7 @@ message only toward an address a stream has proved. Every bundled manager on this slot once its stream proves the address, and never before. **Use Case**: A byte stream the platform established to exactly one peer: a -Wi-Fi Direct group socket, a Multipeer session, a TCP connection over a LAN or +Wi-Fi Direct group socket, a TCP connection over a LAN, over AWDL, or over a routed mesh. Higher bandwidth than BLE, whole messages in one write. **Files**: @@ -175,10 +175,13 @@ transport. Wi-Fi Direct settings (or by another app) is joined when `WIFI_P2P_CONNECTION_CHANGED_ACTION` reports it, and a client reconnects to its group owner while the group lasts. -- iOS: `MultipeerConnectivity`, which fills this slot on iOS, with - `PeerStreamSession` for the per-peer rules. Each message is one frame, and - the service type is `offlineprotocol`, so an app lists - `_offlineprotocol._tcp` and `_offlineprotocol._udp` in `NSBonjourServices`. +- iOS: Network framework TCP streams, on the LAN or over AWDL, with + `PeerStreamReader` cutting frames and `PeerStreamSession` for the + per-stream rules. It advertises and browses `_offlineprotocol._tcp` with + `addr=`, the same record hosts use, so an app lists `_offlineprotocol._tcp` + in `NSBonjourServices`. Of two streams for one address it keeps the one + the lower address opened + ([ADR 0027](adr/0027-ios-peer-streams-ride-network-framework.md)). - Hosts: any TCP socket; DNS-SD `_offlineprotocol._tcp` with `addr=` is the LAN discovery hint the chapter specifies. The Python binding ships one: `PeerStreamManager` (`bindings/python/offline_protocol_sdk/peer_stream_manager.py`), From 1f425e4aada667bb41779064e69ecce5ca6f46ea Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Wed, 30 Sep 2026 22:58:18 +0400 Subject: [PATCH 04/16] fix(bindings): read peer-stream frames in one copy per chunk, and test 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. --- bindings/react-native/ios/Package.swift | 1 + .../react-native/ios/PeerStreamFraming.swift | 18 ++-- .../react-native/ios/PeerStreamSession.swift | 64 +++++++++++++ .../react-native/ios/WifiDirectManager.swift | 38 ++++---- .../ios/tests/PeerStreamDialPolicyTests.swift | 92 +++++++++++++++++++ .../ios/tests/PeerStreamReaderTests.swift | 11 +++ crates/offline-protocol-uniffi/src/lib.rs | 4 +- docs/bridges/swift.md | 6 +- 8 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift diff --git a/bindings/react-native/ios/Package.swift b/bindings/react-native/ios/Package.swift index 58641c2e2..9424b699c 100644 --- a/bindings/react-native/ios/Package.swift +++ b/bindings/react-native/ios/Package.swift @@ -132,6 +132,7 @@ let package = Package( "NostrQueryTrackerTests.swift", "OutboundFragmentQueueTests.swift", "PeerIdentityBindingTests.swift", + "PeerStreamDialPolicyTests.swift", "PeerStreamFramingTests.swift", "PeerStreamReaderTests.swift", "PeerStreamSessionTests.swift", diff --git a/bindings/react-native/ios/PeerStreamFraming.swift b/bindings/react-native/ios/PeerStreamFraming.swift index e6cc9e95a..a0411666a 100644 --- a/bindings/react-native/ios/PeerStreamFraming.swift +++ b/bindings/react-native/ios/PeerStreamFraming.swift @@ -101,8 +101,11 @@ final class PeerStreamReader { guard !refused else { return [] } buffer.append(chunk) var out: [Result] = [] - while buffer.count >= PeerStreamFraming.prefixBytes { - let start = buffer.startIndex + // Frames are read at an offset and the buffer compacted once at the + // end. Compacting per frame re-copied the rest of the buffer each + // time, so a chunk of many small frames cost its size squared. + var start = buffer.startIndex + while buffer.endIndex - start >= PeerStreamFraming.prefixBytes { let length = UInt32(buffer[start]) << 24 | UInt32(buffer[start + 1]) << 16 | UInt32(buffer[start + 2]) << 8 | UInt32(buffer[start + 3]) // The floor is `unframe`'s to check, which knows the position. @@ -112,10 +115,13 @@ final class PeerStreamReader { out.append(.failure(refusal)) return out } - let total = PeerStreamFraming.prefixBytes + Int(length) - guard buffer.count >= total else { break } - out.append(.success(Data(buffer.prefix(total)))) - buffer = Data(buffer.dropFirst(total)) + let end = start + PeerStreamFraming.prefixBytes + Int(length) + guard buffer.endIndex >= end else { break } + out.append(.success(Data(buffer[start.. { host?.peerStreamDiagnostic("warning", "Peer stream refused", ["reason": reason]) } } + +/// When to dial an advertised address, and when to dial it again. +/// +/// Foundation-only so `PeerStreamDialPolicyTests` drives it without a +/// network; `WifiDirectManager` owns the timers and the connections and asks +/// this for every decision. Not thread-safe: the manager calls it from its one +/// serial queue. +/// +/// The ladder climbs only on a redial this returns. It used to climb on every +/// end of an outbound stream, before the check that declines a redial for an +/// address another stream holds, so the higher address of a pair, whose dial +/// loses to the lower's stream on every first contact, climbed a step per +/// lost race and waited out a long delay the first time it truly had to +/// reconnect. +struct PeerStreamDialPolicy { + /// How long the higher address of a pair waits before dialing, so that + /// the lower one's stream, which both ends keep, usually arrives first. + static let higherAddressDelay: TimeInterval = 5.0 + static let redialInitialDelay: TimeInterval = 1.0 + static let redialMaxDelay: TimeInterval = 60.0 + + /// Addresses with a dial scheduled or an outbound stream open. + private var dialing = Set() + private var redialDelay: [String: TimeInterval] = [:] + + /// A peer advertised `address`. The delay to dial after, or nil when no + /// dial is due: one is already under way, or a stream holds the address. + mutating func discovered(_ address: String, weAreLower: Bool, held: Bool) -> TimeInterval? { + return schedule(address, held: held, after: weAreLower ? 0 : Self.higherAddressDelay) + } + + /// An outbound stream toward `address` ended. The delay to redial after, + /// or nil when the advert is gone or another stream holds the address. + mutating func ended(_ address: String, advertised: Bool, held: Bool) -> TimeInterval? { + dialing.remove(address) + guard advertised else { return nil } + let delay = redialDelay[address] ?? Self.redialInitialDelay + guard schedule(address, held: held, after: delay) != nil else { return nil } + redialDelay[address] = min(delay * 2, Self.redialMaxDelay) + return delay + } + + /// A scheduled dial opened nothing (the advert went, the address is now + /// held, the transport paused, or the stream budget is spent). + mutating func abandoned(_ address: String) { + dialing.remove(address) + } + + /// An outbound stream toward `address` proved it: the ladder starts over. + mutating func proved(_ address: String) { + redialDelay.removeValue(forKey: address) + } + + mutating func reset() { + dialing = [] + redialDelay = [:] + } + + private mutating func schedule(_ address: String, held: Bool, after delay: TimeInterval) -> TimeInterval? { + guard !held, !dialing.contains(address) else { return nil } + dialing.insert(address) + return delay + } +} diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index adab63538..354ea7b4e 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -35,8 +35,8 @@ import Network /// the browser reports that as a diagnostic instead of failing silently. /// - Both ends of a pair may dial. Which of two streams for one address is /// kept is decided by `PeerStreamLinks`, the same way on both ends, so the -/// lower address dials at once and the higher waits `HIGHER_ADDRESS_DIAL_DELAY` -/// to see whether the lower one reached it first. Without the wait every +/// lower address dials at once and the higher waits +/// `PeerStreamDialPolicy.higherAddressDelay` to see whether the lower one reached it first. Without the wait every /// first contact would open two streams and close one. /// /// Everything Network framework calls back with, and every per-stream step, @@ -71,11 +71,6 @@ public class WifiDirectManager: NSObject, TransportManager { private let PREAMBLE_TIMEOUT: TimeInterval = 10.0 /// Open streams, proved or not. Android's `Limits.maxStreams`. private static let MAX_STREAMS = 16 - /// How long the higher address of a pair waits before dialing, so that - /// the lower one's stream, which would win, usually arrives first. - private static let HIGHER_ADDRESS_DIAL_DELAY: TimeInterval = 5.0 - private static let REDIAL_INITIAL_DELAY: TimeInterval = 1.0 - private static let REDIAL_MAX_DELAY: TimeInterval = 60.0 /// A listener or browser that failed is rebuilt after this long. private static let REBUILD_DELAY: TimeInterval = 5.0 /// Android's `Limits`: queued bytes toward one peer beyond which a body @@ -140,9 +135,8 @@ public class WifiDirectManager: NSObject, TransportManager { private var streams = Set() /// The endpoint each advertised address is reachable at, while it is. private var adverts: [String: NWEndpoint] = [:] - /// Addresses with an outbound stream open or a dial scheduled. - private var dialing = Set() - private var redialDelay: [String: TimeInterval] = [:] + /// When to dial each advertised address, and when to dial it again. + private var dialPolicy = PeerStreamDialPolicy() /// Bumped by every start() and stop(), so a timer armed for one run finds /// nothing to act on in the next. private var generation = 0 @@ -304,8 +298,7 @@ public class WifiDirectManager: NSObject, TransportManager { browser = nil streams = [] adverts = [:] - dialing = [] - redialDelay = [:] + dialPolicy.reset() // Report every proved peer lost while the core still holds its // link, before the layer goes down, on linkQueue so no delivery @@ -554,7 +547,10 @@ public class WifiDirectManager: NSObject, TransportManager { // the lower one dials at once and the higher gives it time. let weAreLower = PeerStreamLinks.newStreamWins( outbound: true, localAddress: local, peer: address) - scheduleDial(address, after: weAreLower ? 0 : Self.HIGHER_ADDRESS_DIAL_DELAY) + if let delay = dialPolicy.discovered( + address, weAreLower: weAreLower, held: peers.handle(for: address) != nil) { + scheduleDial(address, after: delay) + } case .removed(let result): if let address = Self.advertisedAddress(result) { adverts.removeValue(forKey: address) @@ -571,9 +567,8 @@ public class WifiDirectManager: NSObject, TransportManager { return address } + /// Arms a dial `dialPolicy` decided on. private func scheduleDial(_ address: String, after delay: TimeInterval) { - guard !dialing.contains(address), peers.handle(for: address) == nil else { return } - dialing.insert(address) let expected = generation linkQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self, self.generation == expected else { return } @@ -586,7 +581,7 @@ public class WifiDirectManager: NSObject, TransportManager { private func dial(_ address: String) { guard !isPaused, let endpoint = adverts[address], peers.handle(for: address) == nil, streams.count < Self.MAX_STREAMS else { - dialing.remove(address) + dialPolicy.abandoned(address) return } let stream = Stream( @@ -640,7 +635,7 @@ public class WifiDirectManager: NSObject, TransportManager { } } if let address = stream.dialed, self.peers.handle(for: address) == stream { - self.redialDelay.removeValue(forKey: address) + self.dialPolicy.proved(address) } } if isComplete || error != nil { @@ -660,11 +655,10 @@ public class WifiDirectManager: NSObject, TransportManager { peers.ended(stream) stream.writes.reset() stream.connection.cancel() - guard let address = stream.dialed else { return } - dialing.remove(address) - guard adverts[address] != nil else { return } - let delay = redialDelay[address] ?? Self.REDIAL_INITIAL_DELAY - redialDelay[address] = min(delay * 2, Self.REDIAL_MAX_DELAY) + guard let address = stream.dialed, + let delay = dialPolicy.ended( + address, advertised: adverts[address] != nil, + held: peers.handle(for: address) != nil) else { return } scheduleDial(address, after: delay) } diff --git a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift new file mode 100644 index 000000000..d7ce453cd --- /dev/null +++ b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift @@ -0,0 +1,92 @@ +// +// PeerStreamDialPolicyTests.swift +// +// Pins when the iOS peer-stream manager dials an advertised address and when +// it dials again (ADR 0027): the lower address at once, the higher after a +// grace delay, a redial ladder that climbs only on a redial actually +// scheduled, and no dial toward an address a stream already holds. The +// manager owns the timers and connections, which need a device; this is the +// decision it makes with them. +// + +import XCTest +@testable import OfflineProtocol + +final class PeerStreamDialPolicyTests: XCTestCase { + + private let peer = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn" + + func testTheLowerAddressDialsAtOnceAndTheHigherWaits() { + var lower = PeerStreamDialPolicy() + XCTAssertEqual(lower.discovered(peer, weAreLower: true, held: false), 0) + var higher = PeerStreamDialPolicy() + XCTAssertEqual(higher.discovered(peer, weAreLower: false, held: false), + PeerStreamDialPolicy.higherAddressDelay) + } + + func testNoDialWhileOneIsUnderWayOrTheAddressIsHeld() { + var policy = PeerStreamDialPolicy() + XCTAssertNotNil(policy.discovered(peer, weAreLower: true, held: false)) + XCTAssertNil(policy.discovered(peer, weAreLower: true, held: false), "one dial at a time") + + var held = PeerStreamDialPolicy() + XCTAssertNil(held.discovered(peer, weAreLower: true, held: true)) + } + + func testAnAbandonedDialLetsTheNextAdvertDial() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + policy.abandoned(peer) + XCTAssertEqual(policy.discovered(peer, weAreLower: true, held: false), 0) + } + + func testTheLadderDoublesToItsCap() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + var delays: [TimeInterval] = [] + for _ in 0..<9 { + delays.append(policy.ended(peer, advertised: true, held: false)!) + } + XCTAssertEqual(delays, [1, 2, 4, 8, 16, 32, 60, 60, 60]) + } + + func testALostRaceDoesNotClimbTheLadder() { + // The higher address's dial loses to the lower's stream: its stream + // ends while the address is held. That is no redial, so no step. + var policy = PeerStreamDialPolicy() + for _ in 0..<5 { + _ = policy.discovered(peer, weAreLower: false, held: false) + XCTAssertNil(policy.ended(peer, advertised: true, held: true)) + } + // When the held stream later ends, the first redial is the first step. + XCTAssertEqual(policy.discovered(peer, weAreLower: false, held: false), + PeerStreamDialPolicy.higherAddressDelay) + XCTAssertEqual(policy.ended(peer, advertised: true, held: false), 1) + } + + func testNoRedialOnceTheAdvertIsGone() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + XCTAssertNil(policy.ended(peer, advertised: false, held: false)) + XCTAssertEqual(policy.discovered(peer, weAreLower: true, held: false), 0, + "the ended stream no longer counts as a dial under way") + } + + func testAProvedStreamStartsTheLadderOver() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + _ = policy.ended(peer, advertised: true, held: false) + _ = policy.ended(peer, advertised: true, held: false) + policy.proved(peer) + XCTAssertEqual(policy.ended(peer, advertised: true, held: false), 1) + } + + func testResetForgetsEverything() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + _ = policy.ended(peer, advertised: true, held: false) + policy.reset() + XCTAssertEqual(policy.discovered(peer, weAreLower: true, held: false), 0) + XCTAssertEqual(policy.ended(peer, advertised: true, held: false), 1) + } +} diff --git a/bindings/react-native/ios/tests/PeerStreamReaderTests.swift b/bindings/react-native/ios/tests/PeerStreamReaderTests.swift index 0361364d2..45f31beff 100644 --- a/bindings/react-native/ios/tests/PeerStreamReaderTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamReaderTests.swift @@ -41,6 +41,17 @@ final class PeerStreamReaderTests: XCTestCase { XCTAssertEqual(frames(reader.append(Data(tail))), [three]) } + func testManySmallFramesInOneChunkWithAPartialTail() { + let bodies = (0..<500).map { Data(repeating: UInt8($0 % 251), count: 150) } + let framed = bodies.map(frame) + var chunk = framed.reduce(Data(), +) + let tail = frame(Data("tail".utf8)) + chunk.append(tail.prefix(3)) + let reader = PeerStreamReader() + XCTAssertEqual(frames(reader.append(chunk)), framed) + XCTAssertEqual(frames(reader.append(Data(tail.dropFirst(3)))), [tail]) + } + func testAFrameOfExactlyTheCeilingIsRead() { let whole = frame(Data(count: PeerStreamFraming.maxBodyBytes)) let out = PeerStreamReader().append(whole) diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 98890abd2..3c13255b1 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17018,8 +17018,8 @@ mod tests { swift.contains( "onLinkQueueSync { generation += 1 \ let old = (listener: listener, browser: browser, streams: streams) \ - listener = nil browser = nil streams = [] adverts = [:] dialing = [] \ - redialDelay = [:] peers.endAll() old.listener?.cancel()" + listener = nil browser = nil streams = [] adverts = [:] dialPolicy.reset() \ + peers.endAll() old.listener?.cancel()" ), "ios/WifiDirectManager.swift: stop() must forget every stream before endAll()" ); diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index beeebaea9..9068c4a81 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -245,8 +245,10 @@ as a diagnostic naming `NSBonjourServices`. `PeerStreamSessionTests` drives the session with string handles, a fake carrier and a manual clock, `PeerStreamReaderTests` the reader with every -chunking, and `PeerStreamFramingTests` replays the chapter's vectors. The -manager's own handling (advertise, browse, dial) is not covered in CI (C9). +chunking, `PeerStreamDialPolicyTests` when to dial and redial (the ladder +climbs only on a redial actually scheduled), and `PeerStreamFramingTests` +replays the chapter's vectors. The manager's own use of Network framework +(advertise, browse, connect) is not covered in CI (C9). ## S9. Every object is built for the pod's deployment target From 3acd4bcac68e9c5353eaaad059b73cc441f1669f Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 14:51:03 +0400 Subject: [PATCH 05/16] test(bindings): a Python peer-stream host for testing the iOS manager 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. --- tools/peer-stream-host/peer_stream_host.py | 351 +++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tools/peer-stream-host/peer_stream_host.py diff --git a/tools/peer-stream-host/peer_stream_host.py b/tools/peer-stream-host/peer_stream_host.py new file mode 100644 index 000000000..faccc5543 --- /dev/null +++ b/tools/peer-stream-host/peer_stream_host.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""A Python peer-stream host for testing the iOS manager with one iPhone. + +The Mac plays the second device: it advertises and browses +``_offlineprotocol._tcp`` on the LAN, proves its address with the preamble, +and logs every connect, message, loss and stream decision with a timestamp. +ADR 0027 keeps, of two streams for one address, the one the lower address +opened, so every check below is worth running twice: once with a Mac +identity whose address sorts below the iPhone's, once above it. + +Setup, from the repository root:: + + cd bindings/python && bash scripts/build-desktop.sh + python3 -m venv .venv && . .venv/bin/activate + pip install -e '.[lan]' + +The iPhone runs the example app (appId ``offline-messenger``) with the +Wi-Fi Direct transport on and Internet off, so nothing reaches it through a +relay instead. Mac and iPhone join the same Wi-Fi network. + +Usage:: + + # Make two identities, one sorting below the iPhone's address, one above. + python3 tools/peer-stream-host/peer_stream_host.py identities --iphone off1... + + # Run as one of them. Type `help` for the commands. + python3 tools/peer-stream-host/peer_stream_host.py run --profile low + +What each check should show: + +1. One stream, no loop. `PEER UP` once, messages both ways with + ``transport=wifiDirect``, and no `FLAP?` line over five minutes. +2. Many frames and a large transfer: `burst 500` and `file 2048`. +3. Clean kill: Ctrl-C here, the iPhone reports the peer lost once; start + again, it reconnects. +4. Silent death: turn the Mac's Wi-Fi off while idle; the iPhone loses the + peer within about thirty seconds (keepalive); Wi-Fi on, it reconnects. +5. Background: background the app and bring it back; here `PEER DOWN` once, + then `PEER UP` again. +6. Permission denied: reinstall the app and deny local-network access; the + app logs the denial as an error diagnostic, and nothing connects. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import os +import shutil +import sys +import time +from pathlib import Path +from typing import Any + +from offline_protocol_sdk import ProtocolManager +from offline_protocol_sdk.offline_protocol import OverflowPolicy, ProtocolConfig + +HOME = Path(os.environ.get("PEER_STREAM_HOST_HOME", Path.home() / ".offline-protocol-peer-stream-host")) +#: Seconds between stream-metric samples. +SAMPLE_SECONDS = 10 +#: Consecutive samples with a new supersede or refusal before `FLAP?`. +FLAP_SAMPLES = 3 + + +def log(tag: str, text: str = "") -> None: + print(f"{time.strftime('%H:%M:%S')} {tag:<10} {text}", flush=True) + + +def config(profile: str, app_id: str) -> ProtocolConfig: + return ProtocolConfig( + app_id=app_id, + profile=profile, + ble_enabled=False, + wifi_direct_enabled=True, + internet_enabled=False, + reticulum_enabled=False, + nostr_enabled=False, + prefer_online=False, + initial_ttl=3, + encryption_enabled=True, + auto_key_exchange=True, + store_pending=True, + require_encryption=False, + max_pending_per_peer=100, + max_pending_global=1000, + pending_ttl_ms=86_400_000, + overflow_policy=OverflowPolicy.DROP_OLDEST, + ) + + +def manager(profile_dir: Path, app_id: str, handler: Any = None) -> ProtocolManager: + """A manager on the file stores under `profile_dir`, so each identity is a + directory and nothing touches the login keychain. The peer-stream slot is + enabled (the core needs one transport) but runs only once started.""" + key_file = profile_dir / "store.key" + if not key_file.exists(): + profile_dir.mkdir(parents=True, exist_ok=True) + key_file.write_bytes(os.urandom(32)) + key_file.chmod(0o600) + return ProtocolManager( + # One profile name for every directory: the name selects the account + # inside the store, so naming it after the directory would give a + # renamed candidate a fresh identity. + config("peer-stream-host", app_id), + event_handler=handler, + store_key=key_file.read_bytes(), + mls_root=profile_dir / "mls", + state_root=profile_dir / "state", + ) + + +async def address_of(profile_dir: Path, app_id: str) -> str: + async with manager(profile_dir, app_id) as pm: + address = pm.local_address + if not address: + raise SystemExit(f"{profile_dir} has no address: MLS did not initialise") + return address + + +def is_lower(a: str, b: str) -> bool: + """The order ADR 0027 uses: UTF-8 bytes, which is Python's `<` on str.""" + return a < b + + +async def identities(args: argparse.Namespace) -> None: + iphone = args.iphone.strip() + if not iphone.startswith("off1"): + raise SystemExit("--iphone takes the iPhone's off1... address") + wanted = {"low": lambda a: is_lower(a, iphone), "high": lambda a: is_lower(iphone, a)} + for name, fits in wanted.items(): + target = HOME / name + if target.exists(): + address = await address_of(target, args.app_id) + if fits(address): + log("KEEP", f"{name}: {address}") + continue + shutil.rmtree(target) + # About half of all addresses fit, so this rarely takes more than two. + for attempt in range(1, 33): + candidate = HOME / f"candidate-{attempt}" + shutil.rmtree(candidate, ignore_errors=True) + address = await address_of(candidate, args.app_id) + if fits(address): + candidate.rename(target) + log("MADE", f"{name}: {address}") + break + shutil.rmtree(candidate) + else: + raise SystemExit(f"no {name} identity after 32 tries") + log("IPHONE", iphone) + log("ORDER", "low < iPhone < high: with `low` the Mac's streams win, with `high` the iPhone's") + + +class Host: + def __init__(self, args: argparse.Namespace) -> None: + self.args = args + self.local = "" + self.pm: ProtocolManager | None = None + self.last_metrics: dict[str, Any] = {} + self.flap_samples = 0 + # The core reports a neighbour again on every inbound body, so only + # the first report after a loss is news. + self.neighbors: set[str] = set() + self.delivered = 0 + self.delivered_logged = 0 + + # -- what the core and the stream layer report ---------------------------- + + def on_event(self, event: dict[str, Any]) -> None: + kind = event.get("type", "?") + if kind == "message_received": + transport = event.get("transport", "?") + # Events spell the slot `wifiDirect`; other surfaces `wifi_direct`. + on_slot = transport.replace("_", "").lower() == "wifidirect" + warn = "" if on_slot else " <-- NOT the peer stream" + content = event.get("content", "") + shown = content if len(content) <= 80 else f"{content[:77]}..." + log("RECV", f"from {short(event.get('sender', '?'))} transport={transport} " + f"{len(content)}B {shown!r}{warn}") + elif kind == "file_received": + import base64 + + data = base64.b64decode(event.get("file_data", "")) + log("FILE", f"{event.get('file_name')} {len(data)}B sha256={hashlib.sha256(data).hexdigest()[:16]} " + f"(declared {event.get('file_size')}B)") + elif kind == "neighbor_discovered": + peer = event.get("peer_id", "?") + if peer not in self.neighbors or self.args.verbose: + self.neighbors.add(peer) + log("NEIGHBOR+", f"{short(peer)} via {event.get('transport')}") + elif kind == "neighbor_lost": + peer = event.get("peer_id", "?") + self.neighbors.discard(peer) + log("NEIGHBOR-", short(peer)) + elif kind == "message_delivered" and not self.args.verbose: + self.delivered += 1 # summarised by `watch`, a burst is hundreds + elif kind in ("message_delivered", "message_failed", "transport_switched"): + detail = {k: v for k, v in event.items() if k != "type"} + log(kind.upper()[:10], str(detail)) + elif self.args.verbose: + detail = {k: v for k, v in event.items() if k != "type"} + log("EVENT", f"{kind} {str(detail)[:400]}") + + def on_diagnostic(self, level: str, message: str, context: dict[str, Any]) -> None: + if level == "debug" and not self.args.verbose: + return + peer = context.get("peer") + extra = f" peer={short(peer)} ({self.order(peer)})" if peer else "" + rest = {k: v for k, v in context.items() if k != "peer"} + log(f"STREAM:{level[:4]}", f"{message}{extra}{' ' + str(rest) if rest else ''}") + + def order(self, peer: str) -> str: + if is_lower(self.local, peer): + return "we are lower: the stream we open wins" + return "we are higher: the stream it opens wins" + + # -- the run ------------------------------------------------------------- + + async def run(self) -> None: + profile_dir = HOME / self.args.profile + if not profile_dir.exists(): + # Fine for a first run, which only learns the iPhone's address. + log("NEW", f"no identity {profile_dir.name} yet; making one") + async with manager(profile_dir, self.args.app_id, handler=self.on_event) as pm: + self.pm = pm + self.local = pm.local_address or "" + stream = pm.peer_stream + assert stream is not None + stream.set_delegate(on_diagnostic=self.on_diagnostic) + stream.configure(listen_port=self.args.port, advertise=True, discover=not self.args.no_discover) + await stream.start() + log("UP", f"{self.args.profile} {self.local} listening on {stream.listen_port}") + log("HINT", "type `help` for commands") + watcher = asyncio.create_task(self.watch()) + try: + await self.commands() + finally: + watcher.cancel() + + async def watch(self) -> None: + """Samples the stream layer. A supersede or refusal is normal once per + reconnect; one in every sample is the reconnect loop ADR 0027 names.""" + assert self.pm is not None and self.pm.peer_stream is not None + stream = self.pm.peer_stream + peers: set[str] = set() + while True: + now = set(stream.connected_peers()) + for peer in sorted(now - peers): + log("PEER UP", f"{peer} ({self.order(peer)})") + for peer in sorted(peers - now): + log("PEER DOWN", peer) + peers = now + metrics = stream.get_metrics() + churn = sum( + metrics[k] - self.last_metrics.get(k, metrics[k]) + for k in ("streams_superseded", "streams_refused") + ) + self.flap_samples = self.flap_samples + 1 if churn else 0 + if self.flap_samples >= FLAP_SAMPLES: + log("FLAP?", f"streams superseded or refused in {self.flap_samples} samples running: {metrics}") + self.last_metrics = metrics + if self.delivered != self.delivered_logged: + log("DELIVERED", f"{self.delivered} acknowledged so far (+{self.delivered - self.delivered_logged})") + self.delivered_logged = self.delivered + await asyncio.sleep(SAMPLE_SECONDS if peers else 1) + + async def commands(self) -> None: + loop = asyncio.get_running_loop() + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + await asyncio.Event().wait() # stdin closed: keep logging + words = line.strip().split(maxsplit=1) + if not words: + continue + try: + if not self.command(words[0], words[1] if len(words) > 1 else ""): + return + except Exception as exc: + hint = "" + if "SessionPending" in str(exc): + hint = " (the encrypted session with the peer is still forming; `send` something, then retry)" + log("ERROR", f"{words[0]}: {exc}{hint}") + + def command(self, verb: str, rest: str) -> bool: + assert self.pm is not None and self.pm.peer_stream is not None + if verb in ("quit", "exit"): + return False + if verb == "help": + print(" send TEXT a message to the connected iPhone\n" + " burst N N small messages at once (many frames per read)\n" + " file KB a file of KB kibibytes (chunked by the core)\n" + " peers the addresses with a proved stream\n" + " metrics the stream layer's counters\n" + " quit", flush=True) + elif verb == "peers": + log("PEERS", str(self.pm.peer_stream.connected_peers())) + elif verb == "metrics": + log("METRICS", str(self.pm.peer_stream.get_metrics())) + elif verb == "send": + log("SENT", self.pm.send_message(self.peer(), rest or "hello from the Mac")) + elif verb == "burst": + peer, count = self.peer(), int(rest or "100") + for i in range(count): + self.pm.send_message(peer, f"burst {i + 1}/{count}") + log("SENT", f"{count} messages") + elif verb == "file": + data = os.urandom(int(rest or "1024") * 1024) + file_id = self.pm.protocol.send_file(self.peer(), list(data), f"mac-{len(data)}.bin") + log("SENT", f"file {file_id} {len(data)}B sha256={hashlib.sha256(data).hexdigest()[:16]}") + else: + log("ERROR", f"unknown command {verb!r}; try `help`") + return True + + def peer(self) -> str: + assert self.pm is not None and self.pm.peer_stream is not None + peers = self.pm.peer_stream.connected_peers() + if not peers: + raise RuntimeError("no peer has a proved stream yet") + if len(peers) > 1: + log("NOTE", f"{len(peers)} peers connected; using {peers[0]}") + return peers[0] + + +def short(address: str) -> str: + return address if len(address) <= 20 else f"{address[:12]}...{address[-6:]}" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--app-id", default="offline-messenger", + help="must match the iPhone app's appId (default: the example app's)") + sub = parser.add_subparsers(dest="command", required=True) + make = sub.add_parser("identities", help="make a `low` and a `high` identity around the iPhone's address") + make.add_argument("--iphone", required=True, help="the iPhone's off1... address") + run = sub.add_parser("run", help="run as one identity and log everything") + run.add_argument("--profile", default="low", help="`low`, `high`, or any identity directory name") + run.add_argument("--port", type=int, default=0, help="listen port (default: any free one)") + run.add_argument("--no-discover", action="store_true", + help="advertise only, never dial: only the iPhone can open a stream") + run.add_argument("-v", "--verbose", action="store_true", help="also log debug diagnostics and every event") + args = parser.parse_args() + try: + asyncio.run(identities(args) if args.command == "identities" else Host(args).run()) + except KeyboardInterrupt: + log("DOWN", "stopped") + + +if __name__ == "__main__": + main() From f839d36cd23a776887914743d73205e3067a4d59 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 14:56:46 +0400 Subject: [PATCH 06/16] fix(bindings): the iOS peer browser ignores DNS-SD service-instance records 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. --- bindings/react-native/ios/WifiDirectManager.swift | 11 +++++++++-- crates/offline-protocol-uniffi/src/lib.rs | 7 +++++++ docs/bridges/swift.md | 4 +++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 354ea7b4e..2b6f2e55a 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -561,9 +561,16 @@ public class WifiDirectManager: NSObject, TransportManager { } } + /// The peer address a record advertises, or nil for a record that is not + /// a peer hint. A record carrying `sid` is a service instance under the + /// DNS-SD mapping chapter: it names the same host and address as the peer + /// record, so taking it as a peer would open a second stream to one host + /// per service published there. The stream chapter says a peer browser + /// MUST ignore it, as the Python manager does. private static func advertisedAddress(_ result: NWBrowser.Result) -> String? { - guard case .bonjour(let txt) = result.metadata, - let address = txt["addr"], !address.isEmpty else { return nil } + guard case .bonjour(let txt) = result.metadata else { return nil } + if txt.dictionary.keys.contains("sid") { return nil } + guard let address = txt["addr"], !address.isEmpty else { return nil } return address } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 3c13255b1..1df702790 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17299,6 +17299,13 @@ mod tests { .contains("static func == (lhs: Stream, rhs: Stream) -> Bool { lhs === rhs }"), "ios/WifiDirectManager.swift: a Stream must compare by identity" ); + // A service-instance record (`sid`) is never a peer hint: the stream + // chapter's MUST, which the Python browser already keeps. Taking one + // as a peer opens a second stream to the same host per service. + assert!( + ios_manager.contains("if txt.dictionary.keys.contains(\"sid\") { return nil }"), + "ios/WifiDirectManager.swift: the browser must ignore records carrying `sid`" + ); // Our own advert is skipped by the address it carries. assert!( ios_manager.contains( diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 9068c4a81..67cdfb286 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -212,7 +212,9 @@ found through DNS-SD `_offlineprotocol._tcp` on the network or over AWDL connected peer is announced to the core only under the address `verifyIdentityAssertion` derived from its first frame, and only then: never on connect, and never from the `addr` in its TXT record, which is only a -claim the preamble must match. +claim the preamble must match. A record carrying `sid` is a service +instance under the [DNS-SD mapping](../spec/dns-sd-mapping.md), not a peer, +and the browser ignores it as the stream chapter requires. The rules live in two Foundation-only pieces. `PeerStreamReader` cuts the stream into whole frames and refuses a prefix out of bounds before it From cec7401ccf0a1f9b8026abc5c15e355625e50f02 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 20:07:53 +0400 Subject: [PATCH 07/16] docs(transport): the iOS peer stream is a TCP connection, not a Multipeer session --- crates/offline-protocol-transport/src/stream_framing.rs | 4 ++-- crates/offline-protocol-transport/src/wifi_direct.rs | 5 +++-- crates/offline-protocol-uniffi/src/offline_protocol.udl | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/offline-protocol-transport/src/stream_framing.rs b/crates/offline-protocol-transport/src/stream_framing.rs index 5feb336b0..2703ded80 100644 --- a/crates/offline-protocol-transport/src/stream_framing.rs +++ b/crates/offline-protocol-transport/src/stream_framing.rs @@ -2,8 +2,8 @@ //! //! The chapter is `docs/spec/stream-framing.md`. A peer stream is a byte //! stream the platform established to exactly one other device (a Wi-Fi -//! Direct group socket, a Multipeer session, a TCP connection over a LAN or a -//! routed mesh), and the engine holds every such stream in the slot the FFI +//! Direct group socket, a TCP connection over a LAN, AWDL or a routed +//! mesh), and the engine holds every such stream in the slot the FFI //! names `wifi_direct`. A stream gets two things from its platform for free, //! bytes in order and a close; this module is the one Rust implementation of //! the two it does not: a frame boundary, and the peer's proved address. diff --git a/crates/offline-protocol-transport/src/wifi_direct.rs b/crates/offline-protocol-transport/src/wifi_direct.rs index b4c2324a0..e73ed36be 100644 --- a/crates/offline-protocol-transport/src/wifi_direct.rs +++ b/crates/offline-protocol-transport/src/wifi_direct.rs @@ -1,8 +1,9 @@ //! The peer-stream transport: the queue engine behind the `wifi_direct` slot. //! //! A peer stream is a byte stream the platform established to exactly one -//! other device: a Wi-Fi Direct group socket on Android, a Multipeer session -//! on iOS, a TCP connection over a LAN or a routed mesh on a host. To the +//! other device: a Wi-Fi Direct group socket on Android, a TCP connection +//! over a LAN or AWDL on iOS, a TCP connection over a LAN or a routed mesh on +//! a host. To the //! engine they are one transport, and this is it. The name is historical and //! recorded as debt in `docs/spec/stream-framing.md`; renaming the slot is a //! separate breaking change. diff --git a/crates/offline-protocol-uniffi/src/offline_protocol.udl b/crates/offline-protocol-uniffi/src/offline_protocol.udl index 96e2dbe43..769b5b91e 100644 --- a/crates/offline-protocol-uniffi/src/offline_protocol.udl +++ b/crates/offline-protocol-uniffi/src/offline_protocol.udl @@ -1386,8 +1386,8 @@ interface OfflineProtocol { // Peer-stream transport operations (the `wifi_direct` slot) // // A byte stream the platform established to exactly one peer: a Wi-Fi - // Direct group socket, a Multipeer session, a TCP connection over a LAN - // or a routed mesh. To the engine they are one transport, registered + // Direct group socket, a TCP connection over a LAN, AWDL or a routed + // mesh. To the engine they are one transport, registered // behind `wifi_direct_enabled`; the slot's name is historical, recorded // as debt in docs/spec/stream-framing.md, which is the contract for // everything below. From 502a25ebca031494991408df15750a576978bde9 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 20:20:54 +0400 Subject: [PATCH 08/16] fix(bindings): a stale DNS-SD record no longer takes a live peer's address 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. --- .../react-native/ios/PeerStreamSession.swift | 28 +++++++ .../react-native/ios/WifiDirectManager.swift | 79 ++++++++++++------- .../ios/tests/PeerStreamDialPolicyTests.swift | 28 ++++++- crates/offline-protocol-uniffi/src/lib.rs | 12 ++- ...ios-peer-streams-ride-network-framework.md | 4 + docs/bridges/swift.md | 12 ++- 6 files changed, 132 insertions(+), 31 deletions(-) diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index b06b1b0cb..c546cfd89 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -328,6 +328,34 @@ struct PeerStreamDialPolicy { redialDelay = [:] } + /// The record each advertised address is dialed at, from every peer + /// record the browser holds now (`records`, our own already left out). + /// + /// Rebuilt whole on each change, never patched per record, because one + /// address can be advertised by two records at once: a peer whose record + /// outlived it in our cache, beside the one it published on return. + /// Removing a record by its address took the live record's address with + /// it, and with no advert the address was never dialed again. Of two + /// records for one address, one in `fresh` (added or changed by this + /// change) wins, being the newest; then the one in `current`, which a dial + /// may already be using. + static func adverts( + _ records: [(address: String, endpoint: Endpoint)], + fresh: Set, + current: [String: Endpoint] + ) -> [String: Endpoint] { + func rank(_ address: String, _ endpoint: Endpoint) -> Int { + fresh.contains(endpoint) ? 2 : current[address] == endpoint ? 1 : 0 + } + var out: [String: Endpoint] = [:] + for record in records { + if let kept = out[record.address], + rank(record.address, kept) >= rank(record.address, record.endpoint) { continue } + out[record.address] = record.endpoint + } + return out + } + private mutating func schedule(_ address: String, held: Bool, after delay: TimeInterval) -> TimeInterval? { guard !held, !dialing.contains(address) else { return nil } dialing.insert(address) diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 2b6f2e55a..a19f451c6 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -8,6 +8,7 @@ // the radio's: iOS has no Wi-Fi Direct API (ADR 0027). // +import CryptoKit import Foundation import Network @@ -73,6 +74,10 @@ public class WifiDirectManager: NSObject, TransportManager { private static let MAX_STREAMS = 16 /// A listener or browser that failed is rebuilt after this long. private static let REBUILD_DELAY: TimeInterval = 5.0 + /// How long a dialed stream may wait for a path before it is ended and + /// left to the redial ladder. The connect timeout, so AWDL gets as long to + /// come up as a connect attempt does. + private static let WAITING_GRACE: TimeInterval = 10.0 /// Android's `Limits`: queued bytes toward one peer beyond which a body /// is dropped (the core retries it), and only while that peer's oldest /// write has been outstanding for `WRITE_STALL_MS`. The core hands a burst @@ -435,11 +440,12 @@ public class WifiDirectManager: NSObject, TransportManager { private func startListening() throws { let listener = try NWListener(using: Self.makeParameters()) + let address = protocolInstance.localAddress() listener.service = NWListener.Service( - name: "op-\(UUID().uuidString.prefix(8).lowercased())", + name: Self.instanceName(address: address), type: Self.SERVICE_TYPE, domain: nil, - txtRecord: Self.txtRecord(address: protocolInstance.localAddress()) + txtRecord: Self.txtRecord(address: address) ) listener.stateUpdateHandler = { [weak self, weak listener] newState in guard let self = self, let listener = listener, listener === self.listener else { return } @@ -457,6 +463,17 @@ public class WifiDirectManager: NSObject, TransportManager { listener.start(queue: linkQueue) } + /// A digest of the address, as the Python manager names its own. A + /// restarted listener (every return from the background) then replaces + /// its record in each peer's cache instead of publishing a second one + /// beside the stale one. Random only while there is no identity, when the + /// record carries no address and no browser dials it. + static func instanceName(address: String?) -> String { + guard let address = address else { return "op-\(UUID().uuidString.prefix(8).lowercased())" } + let digest = SHA256.hash(data: Data(address.utf8)) + return "op-" + digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + private func listenerChanged(_ newState: NWListener.State) { switch newState { case .waiting(let error): @@ -492,9 +509,9 @@ public class WifiDirectManager: NSObject, TransportManager { guard let self = self, let browser = browser, browser === self.browser else { return } self.browserChanged(newState) } - browser.browseResultsChangedHandler = { [weak self, weak browser] _, changes in + browser.browseResultsChangedHandler = { [weak self, weak browser] results, changes in guard let self = self, let browser = browser, browser === self.browser else { return } - self.advertsChanged(changes) + self.advertsChanged(results, changes) } self.browser = browser browser.start(queue: linkQueue) @@ -533,30 +550,31 @@ public class WifiDirectManager: NSObject, TransportManager { } } - /// Records each advertised address and dials it. An advert with no `addr` + /// Records each advertised address from every record the browser holds, + /// and dials the ones a change added or changed. An advert with no `addr` /// is a device with no identity yet, which has no preamble to send, and /// ours is skipped by its address. - private func advertsChanged(_ changes: Set) { + private func advertsChanged(_ results: Set, _ changes: Set) { + guard let local = protocolInstance.localAddress() else { return } + var records: [(address: String, endpoint: NWEndpoint)] = [] + for result in results { + guard let address = Self.advertisedAddress(result), address != local else { continue } + records.append((address, result.endpoint)) + } + var fresh = Set() for change in changes { - switch change { - case .added(let result), .changed(_, let result, _): - guard let address = Self.advertisedAddress(result), - let local = protocolInstance.localAddress(), address != local else { continue } - adverts[address] = result.endpoint - // The lower address's stream is the one both ends keep, so - // the lower one dials at once and the higher gives it time. - let weAreLower = PeerStreamLinks.newStreamWins( - outbound: true, localAddress: local, peer: address) - if let delay = dialPolicy.discovered( - address, weAreLower: weAreLower, held: peers.handle(for: address) != nil) { - scheduleDial(address, after: delay) - } - case .removed(let result): - if let address = Self.advertisedAddress(result) { - adverts.removeValue(forKey: address) - } - default: - break + if case .added(let result) = change { fresh.insert(result.endpoint) } + if case .changed(_, let result, _) = change { fresh.insert(result.endpoint) } + } + adverts = PeerStreamDialPolicy.adverts(records, fresh: fresh, current: adverts) + for (address, endpoint) in adverts where fresh.contains(endpoint) { + // The lower address's stream is the one both ends keep, so the + // lower one dials at once and the higher gives it time. + let weAreLower = PeerStreamLinks.newStreamWins( + outbound: true, localAddress: local, peer: address) + if let delay = dialPolicy.discovered( + address, weAreLower: weAreLower, held: peers.handle(for: address) != nil) { + scheduleDial(address, after: delay) } } } @@ -611,9 +629,14 @@ public class WifiDirectManager: NSObject, TransportManager { self.peers.connected(stream) self.receive(on: stream) case .waiting: - // The peer is not reachable now. Ended rather than left - // waiting, so the redial ladder decides when to try again. - stream.connection.cancel() + // No path to the peer yet, which over AWDL can be the link + // still coming up. Ended if it is still waiting after the + // grace, so the redial ladder decides when to try again + // rather than a connection that waits forever. + self.linkQueue.asyncAfter(deadline: .now() + Self.WAITING_GRACE) { [weak stream] in + guard let stream = stream, case .waiting = stream.connection.state else { return } + stream.connection.cancel() + } case .failed, .cancelled: self.end(stream) default: diff --git a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift index d7ce453cd..d2a98e6be 100644 --- a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift @@ -4,7 +4,8 @@ // Pins when the iOS peer-stream manager dials an advertised address and when // it dials again (ADR 0027): the lower address at once, the higher after a // grace delay, a redial ladder that climbs only on a redial actually -// scheduled, and no dial toward an address a stream already holds. The +// scheduled, no dial toward an address a stream already holds, and an +// address that stays advertised while any record for it is live. The // manager owns the timers and connections, which need a device; this is the // decision it makes with them. // @@ -89,4 +90,29 @@ final class PeerStreamDialPolicyTests: XCTestCase { XCTAssertEqual(policy.discovered(peer, weAreLower: true, held: false), 0) XCTAssertEqual(policy.ended(peer, advertised: true, held: false), 1) } + + /// A peer back from the background before its old record expired is + /// advertised by two records; the old one going must leave the address. + func testRemovingAStaleRecordKeepsTheLiveOnesAddress() { + var adverts = PeerStreamDialPolicy.adverts( + [(address: peer, endpoint: "old")], fresh: ["old"], current: [:]) + adverts = PeerStreamDialPolicy.adverts( + [(address: peer, endpoint: "old"), (address: peer, endpoint: "new")], + fresh: ["new"], current: adverts) + XCTAssertEqual(adverts[peer], "new", "the record a change added is the newest") + adverts = PeerStreamDialPolicy.adverts( + [(address: peer, endpoint: "new")], fresh: [], current: adverts) + XCTAssertEqual(adverts[peer], "new") + XCTAssertNil(PeerStreamDialPolicy.adverts( + [(address: String, endpoint: String)](), fresh: [], current: adverts)[peer], + "the last record going takes the address") + } + + func testTheRecordInUseStaysOverAnOlderOne() { + let records = [(address: peer, endpoint: "a"), (address: peer, endpoint: "b")] + for current in ["a", "b"] { + XCTAssertEqual(PeerStreamDialPolicy.adverts( + records, fresh: [], current: [peer: current])[peer], current) + } + } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 1df702790..449103d07 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17309,10 +17309,20 @@ mod tests { // Our own advert is skipped by the address it carries. assert!( ios_manager.contains( - "let local = protocolInstance.localAddress(), address != local else { continue }" + "guard let address = Self.advertisedAddress(result), address != local else { continue }" ), "ios/WifiDirectManager.swift: the browser must skip our own address" ); + // The adverts are rebuilt from every record the browser holds, never + // removed by address: a peer back from the background is advertised by + // its stale record and its new one at once, and removing the stale one + // by address dropped the live one's too, so the peer was never redialed. + assert!( + ios_manager.contains( + "adverts = PeerStreamDialPolicy.adverts(records, fresh: fresh, current: adverts)" + ) && !ios_manager.contains("adverts.removeValue(forKey:"), + "ios/WifiDirectManager.swift: the browser must rebuild its adverts from the full result set" + ); // A group client redials the owner the group has NOW. On a group // switch the new owner's dial loses the one-outbound-socket flag to diff --git a/docs/adr/0027-ios-peer-streams-ride-network-framework.md b/docs/adr/0027-ios-peer-streams-ride-network-framework.md index a53997dc8..d0d2fa2be 100644 --- a/docs/adr/0027-ios-peer-streams-ride-network-framework.md +++ b/docs/adr/0027-ios-peer-streams-ride-network-framework.md @@ -108,6 +108,10 @@ seconds idle, 5 between probes, 3 probes. carriers. - A local-network denial is reported as a diagnostic. Multipeer failed silently there. +- When this was accepted, the manager had been run on one iPhone against a + Python host on a shared network, in both address orders. Two iPhones over + AWDL with no access point, the case Multipeer covered, had not been run on + devices. It is owed before a release claims it. ## What would undo this diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 67cdfb286..58cf2c43d 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -238,6 +238,15 @@ connection, never an id a later stream could share: the Multipeer manager this replaced keyed its peers by an id that survived a restart, and a reconnect met state that said its preamble was already sent. +The browser rebuilds its adverts from every record it holds after each +change, never removing one by its address. A peer back from the background +can be advertised by the record it just published and by its old one still in +our cache, and removing the old one by address took the live one's address +with it, so the peer was never dialed again. The instance name is a digest of +the address, as on Python, so a restarted listener replaces its record +instead of adding a second. A dial with no path yet (`.waiting`) gets ten +seconds, the connect timeout, before the redial ladder takes it over. + Writes follow Android's bounds: a body is dropped (the core retries it) when more than 4 MiB is queued toward a peer whose oldest write has been outstanding for two seconds, and a write outstanding for thirty seconds ends @@ -248,7 +257,8 @@ as a diagnostic naming `NSBonjourServices`. `PeerStreamSessionTests` drives the session with string handles, a fake carrier and a manual clock, `PeerStreamReaderTests` the reader with every chunking, `PeerStreamDialPolicyTests` when to dial and redial (the ladder -climbs only on a redial actually scheduled), and `PeerStreamFramingTests` +climbs only on a redial actually scheduled, and a stale record never takes +a live one's address), and `PeerStreamFramingTests` replays the chapter's vectors. The manager's own use of Network framework (advertise, browse, connect) is not covered in CI (C9). From 32773675d63bcd687ce373153dbb32f48cf78aa2 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 20:30:16 +0400 Subject: [PATCH 09/16] fix(bindings): one LAN host cannot fill the iOS peer-stream listener 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. --- CHANGELOG.md | 3 ++- .../react-native/ios/PeerStreamSession.swift | 20 +++++++++++++++++++ .../react-native/ios/WifiDirectManager.swift | 17 +++++++++++++--- .../ios/tests/PeerStreamDialPolicyTests.swift | 20 +++++++++++++++++-- crates/offline-protocol-uniffi/src/lib.rs | 7 +++++++ ...ios-peer-streams-ride-network-framework.md | 3 +++ docs/bridges/swift.md | 6 +++++- 7 files changed, 69 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecfbab8b..d60ddd0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -583,7 +583,8 @@ archived by series under [docs/changelog/](docs/changelog/); see the `NSLocalNetworkUsageDescription` is still required. A denied local-network permission is now reported as an `error` diagnostic instead of failing silently. The seven-peer cap Multipeer imposed is gone; at most sixteen - streams are open. The hop is plain TCP, as on Android and Python, where + streams are open, twelve of them inbound and four from any one host. The + hop is plain TCP, as on Android and Python, where Multipeer encrypted it; payloads are sealed either way. Both ends of a pair now dial, and of two streams for one address both keep the one the lower address opened, the Python manager's rule diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index c546cfd89..c14789057 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -290,6 +290,19 @@ struct PeerStreamDialPolicy { static let higherAddressDelay: TimeInterval = 5.0 static let redialInitialDelay: TimeInterval = 1.0 static let redialMaxDelay: TimeInterval = 60.0 + /// Streams open at once, proved or not. Android's `Limits.maxStreams`. + static let maxStreams = 16 + /// The inbound share of `maxStreams`. The rest is left to dials, so a + /// listener full of strangers' sockets never stops this device reaching + /// the peers it browses. + static let maxInbound = 12 + /// Inbound streams one remote host may hold, proved or not. A host needs + /// one, two while it reconnects past its own stale stream. Without the + /// bound one machine on the LAN fills the inbound share alone, with silent + /// sockets that each hold a slot for the preamble deadline, or with as + /// many self-made identities. The Python manager's `MAX_STREAMS_PER_HOST`, + /// scaled to this budget. + static let maxInboundPerHost = 4 /// Addresses with a dial scheduled or an outbound stream open. private var dialing = Set() @@ -328,6 +341,13 @@ struct PeerStreamDialPolicy { redialDelay = [:] } + /// Whether the listener takes a connection from `host`, given the hosts + /// of the inbound streams open now and the count of all open streams. + static func admitsInbound(from host: Host, inboundFrom hosts: [Host], open: Int) -> Bool { + return open < maxStreams && hosts.count < maxInbound + && hosts.filter { $0 == host }.count < maxInboundPerHost + } + /// The record each advertised address is dialed at, from every peer /// record the browser holds now (`records`, our own already left out). /// diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index a19f451c6..e552802b3 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -70,8 +70,9 @@ public class WifiDirectManager: NSObject, TransportManager { /// How long a connected peer may take to prove its address. Local policy, /// not wire format (stream-framing.md). private let PREAMBLE_TIMEOUT: TimeInterval = 10.0 - /// Open streams, proved or not. Android's `Limits.maxStreams`. - private static let MAX_STREAMS = 16 + /// Open streams, proved or not. The inbound share and the per-host bound + /// are `PeerStreamDialPolicy.admitsInbound`'s. + private static let MAX_STREAMS = PeerStreamDialPolicy.maxStreams /// A listener or browser that failed is rebuilt after this long. private static let REBUILD_DELAY: TimeInterval = 5.0 /// How long a dialed stream may wait for a path before it is ended and @@ -453,7 +454,10 @@ public class WifiDirectManager: NSObject, TransportManager { } listener.newConnectionHandler = { [weak self, weak listener] connection in guard let self = self, let listener = listener, listener === self.listener, - self.streams.count < Self.MAX_STREAMS else { + PeerStreamDialPolicy.admitsInbound( + from: Self.host(of: connection), + inboundFrom: self.streams.filter { !$0.outbound }.map { Self.host(of: $0.connection) }, + open: self.streams.count) else { connection.cancel() return } @@ -463,6 +467,13 @@ public class WifiDirectManager: NSObject, TransportManager { listener.start(queue: linkQueue) } + /// The remote host an accepted connection came from, nil if unknown + /// (unknowns share one bound). + private static func host(of connection: NWConnection) -> String? { + guard case .hostPort(let host, _) = connection.endpoint else { return nil } + return "\(host)" + } + /// A digest of the address, as the Python manager names its own. A /// restarted listener (every return from the background) then replaces /// its record in each peer's cache instead of publishing a second one diff --git a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift index d2a98e6be..b9c27c00c 100644 --- a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift @@ -4,8 +4,9 @@ // Pins when the iOS peer-stream manager dials an advertised address and when // it dials again (ADR 0027): the lower address at once, the higher after a // grace delay, a redial ladder that climbs only on a redial actually -// scheduled, no dial toward an address a stream already holds, and an -// address that stays advertised while any record for it is live. The +// scheduled, no dial toward an address a stream already holds, an address +// that stays advertised while any record for it is live, and how many +// inbound streams the listener takes. The // manager owns the timers and connections, which need a device; this is the // decision it makes with them. // @@ -115,4 +116,19 @@ final class PeerStreamDialPolicyTests: XCTestCase { records, fresh: [], current: [peer: current])[peer], current) } } + + /// One machine on the LAN cannot fill the listener, and a full listener + /// leaves room to dial. + func testInboundIsBoundedPerHostAndLeavesRoomToDial() { + let one = Array(repeating: "10.0.0.9", count: PeerStreamDialPolicy.maxInboundPerHost) + XCTAssertFalse(PeerStreamDialPolicy.admitsInbound(from: "10.0.0.9", inboundFrom: one, open: one.count)) + XCTAssertTrue(PeerStreamDialPolicy.admitsInbound(from: "10.0.0.7", inboundFrom: one, open: one.count)) + + let many = (0.. Date: Thu, 1 Oct 2026 20:39:53 +0400 Subject: [PATCH 10/16] fix(bindings): a peer-stream dial with no free slot is tried again 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. --- bindings/react-native/ios/PeerStreamSession.swift | 13 +++++++++++-- bindings/react-native/ios/WifiDirectManager.swift | 9 ++++++--- .../ios/tests/PeerStreamDialPolicyTests.swift | 10 ++++++++++ crates/offline-protocol-uniffi/src/lib.rs | 9 +++++++++ docs/bridges/swift.md | 4 ++-- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index c14789057..83628e322 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -325,12 +325,21 @@ struct PeerStreamDialPolicy { return delay } - /// A scheduled dial opened nothing (the advert went, the address is now - /// held, the transport paused, or the stream budget is spent). + /// A scheduled dial opened nothing because it is not due: the advert + /// went, the address is now held, or the transport paused (resuming + /// rebuilds the browser, which reports every record again). mutating func abandoned(_ address: String) { dialing.remove(address) } + /// A scheduled dial found every stream slot taken. The delay to try again + /// after, on the redial ladder. Abandoning it lost the peer for good: an + /// ending stream redials only its own address, and a browse change dials + /// only a fresh record, so nothing would have dialed it again. + mutating func noSlot(_ address: String) -> TimeInterval? { + return ended(address, advertised: true, held: false) + } + /// An outbound stream toward `address` proved it: the ladder starts over. mutating func proved(_ address: String) { redialDelay.removeValue(forKey: address) diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index e552802b3..72745540d 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -613,13 +613,16 @@ public class WifiDirectManager: NSObject, TransportManager { } /// Opens a stream toward `address`, unless it went away or is already - /// held (an inbound stream got here first). + /// held (an inbound stream got here first), or later when no slot is free. private func dial(_ address: String) { - guard !isPaused, let endpoint = adverts[address], peers.handle(for: address) == nil, - streams.count < Self.MAX_STREAMS else { + guard !isPaused, let endpoint = adverts[address], peers.handle(for: address) == nil else { dialPolicy.abandoned(address) return } + guard streams.count < Self.MAX_STREAMS else { + if let delay = dialPolicy.noSlot(address) { scheduleDial(address, after: delay) } + return + } let stream = Stream( connection: NWConnection(to: endpoint, using: Self.makeParameters()), outbound: true, diff --git a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift index b9c27c00c..efb4beedd 100644 --- a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift @@ -131,4 +131,14 @@ final class PeerStreamDialPolicyTests: XCTestCase { XCTAssertFalse(PeerStreamDialPolicy.admitsInbound( from: "10.0.2.1", inboundFrom: [String](), open: PeerStreamDialPolicy.maxStreams)) } + + /// A dial that found no free slot is tried again, later each time, and + /// stays the one dial under way for its address. + func testADialWithNoSlotIsTriedAgainOnTheLadder() { + var policy = PeerStreamDialPolicy() + _ = policy.discovered(peer, weAreLower: true, held: false) + XCTAssertEqual(policy.noSlot(peer), PeerStreamDialPolicy.redialInitialDelay) + XCTAssertNil(policy.discovered(peer, weAreLower: true, held: false), "one dial at a time") + XCTAssertEqual(policy.noSlot(peer), 2 * PeerStreamDialPolicy.redialInitialDelay) + } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 198482feb..6d1b2f38f 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17330,6 +17330,15 @@ mod tests { ios_manager.contains("PeerStreamDialPolicy.admitsInbound("), "ios/WifiDirectManager.swift: the listener must admit through the per-host bound" ); + // A dial that finds no free slot goes onto the redial ladder. Abandoned, + // it was never dialed again: an ending stream redials only its own + // address, and a browse change dials only a fresh record. + assert!( + ios_manager.contains( + "if let delay = dialPolicy.noSlot(address) { scheduleDial(address, after: delay) }" + ), + "ios/WifiDirectManager.swift: a dial with no free slot must be retried on the ladder" + ); // A group client redials the owner the group has NOW. On a group // switch the new owner's dial loses the one-outbound-socket flag to diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index ba7fe3b19..e44fc25b0 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -261,8 +261,8 @@ as a diagnostic naming `NSBonjourServices`. `PeerStreamSessionTests` drives the session with string handles, a fake carrier and a manual clock, `PeerStreamReaderTests` the reader with every chunking, `PeerStreamDialPolicyTests` when to dial and redial (the ladder -climbs only on a redial actually scheduled, and a stale record never takes -a live one's address), and `PeerStreamFramingTests` +climbs only on a redial actually scheduled, a dial that finds no free slot +waits on it, and a stale record never takes a live one's address), and `PeerStreamFramingTests` replays the chapter's vectors. The manager's own use of Network framework (advertise, browse, connect) is not covered in CI (C9). From 84c9e285baa513c6f737ea6b49eca338967bcc0a Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 20:49:40 +0400 Subject: [PATCH 11/16] test(bindings): pin the iOS peer-stream advert's bytes in the harness 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. --- .../react-native/ios/PeerStreamFraming.swift | 38 ++++++++++++++++++- .../react-native/ios/WifiDirectManager.swift | 33 +--------------- .../ios/tests/PeerStreamFramingTests.swift | 15 ++++++++ crates/offline-protocol-uniffi/src/lib.rs | 10 ++++- docs/bridges/swift.md | 3 +- 5 files changed, 63 insertions(+), 36 deletions(-) diff --git a/bindings/react-native/ios/PeerStreamFraming.swift b/bindings/react-native/ios/PeerStreamFraming.swift index a0411666a..3b3333062 100644 --- a/bindings/react-native/ios/PeerStreamFraming.swift +++ b/bindings/react-native/ios/PeerStreamFraming.swift @@ -4,14 +4,16 @@ // // The parts of docs/spec/stream-framing.md a platform manager owns: the // length prefix, its bounds, the position rule that makes the first body on a -// stream the peer's identity assertion, and one announced stream per address. +// stream the peer's identity assertion, one announced stream per address, and +// the DNS-SD advert a peer finds it by. // -// Foundation only, so the SwiftPM harness tests it without a socket +// Foundation and CryptoKit only, so the SwiftPM harness tests it without a socket // (PeerStreamFramingTests replays the chapter's conformance vectors). // WifiDirectManager owns the connections and calls into this. Mirrors // android's PeerStreamFraming.kt, keep in sync. // +import CryptoKit import Foundation /// Why a frame or a preamble was refused. The stream closes in every case. @@ -78,6 +80,38 @@ enum PeerStreamFraming { // Rebased, so the caller can index from zero. return .success(Data(message.dropFirst(prefixBytes))) } + + /// The DNS-SD TXT record, built by hand because the chapter requires + /// `txtvers=1` to be the first entry and `NWTXTRecord` does not promise an + /// order. `addr` is absent until this device has an identity. An entry + /// longer than its one length byte can say is left out rather than + /// trapping the host app; an address is far shorter. + static func txtRecord(address: String?) -> Data { + var entries = ["txtvers=1"] + if let address = address, !address.isEmpty { + entries.append("addr=\(address)") + } + var out = Data() + for entry in entries { + let bytes = Data(entry.utf8) + guard let length = UInt8(exactly: bytes.count) else { continue } + out.append(length) + out.append(bytes) + } + return out + } + + /// The DNS-SD instance name: a digest of the address, as the Python + /// manager names its own. A restarted listener (every return from the + /// background) then replaces its record in each peer's cache instead of + /// publishing a second one beside the stale one. Random only while there + /// is no identity, when the record carries no address and no browser + /// dials it. + static func instanceName(address: String?) -> String { + guard let address = address else { return "op-\(UUID().uuidString.prefix(8).lowercased())" } + let digest = SHA256.hash(data: Data(address.utf8)) + return "op-" + digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } } /// Cuts whole frames, prefix included, off a byte stream (the chapter's diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 72745540d..ad033381c 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -8,7 +8,6 @@ // the radio's: iOS has no Wi-Fi Direct API (ADR 0027). // -import CryptoKit import Foundation import Network @@ -227,23 +226,6 @@ public class WifiDirectManager: NSObject, TransportManager { return parameters } - /// The DNS-SD TXT record, built by hand because the chapter requires - /// `txtvers=1` to be the first entry and `NWTXTRecord` does not promise an - /// order. `addr` is absent until this device has an identity. - private static func txtRecord(address: String?) -> Data { - var entries = ["txtvers=1"] - if let address = address, !address.isEmpty { - entries.append("addr=\(address)") - } - var out = Data() - for entry in entries { - let bytes = Data(entry.utf8) - out.append(UInt8(bytes.count)) - out.append(bytes) - } - return out - } - // MARK: - TransportManager Implementation public func isAvailable() -> Bool { @@ -443,10 +425,10 @@ public class WifiDirectManager: NSObject, TransportManager { let listener = try NWListener(using: Self.makeParameters()) let address = protocolInstance.localAddress() listener.service = NWListener.Service( - name: Self.instanceName(address: address), + name: PeerStreamFraming.instanceName(address: address), type: Self.SERVICE_TYPE, domain: nil, - txtRecord: Self.txtRecord(address: address) + txtRecord: PeerStreamFraming.txtRecord(address: address) ) listener.stateUpdateHandler = { [weak self, weak listener] newState in guard let self = self, let listener = listener, listener === self.listener else { return } @@ -474,17 +456,6 @@ public class WifiDirectManager: NSObject, TransportManager { return "\(host)" } - /// A digest of the address, as the Python manager names its own. A - /// restarted listener (every return from the background) then replaces - /// its record in each peer's cache instead of publishing a second one - /// beside the stale one. Random only while there is no identity, when the - /// record carries no address and no browser dials it. - static func instanceName(address: String?) -> String { - guard let address = address else { return "op-\(UUID().uuidString.prefix(8).lowercased())" } - let digest = SHA256.hash(data: Data(address.utf8)) - return "op-" + digest.prefix(8).map { String(format: "%02x", $0) }.joined() - } - private func listenerChanged(_ newState: NWListener.State) { switch newState { case .waiting(let error): diff --git a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift index 88f5b1dfa..8a58b76ee 100644 --- a/bindings/react-native/ios/tests/PeerStreamFramingTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamFramingTests.swift @@ -358,4 +358,19 @@ final class PeerStreamFramingTests: XCTestCase { XCTAssertEqual(announce(links, "s1", a), .init(firstForAddress: false, superseded: nil)) XCTAssertEqual(links.handle(for: a), "s1") } + + /// The advert is the only way a peer finds this one, so a wrong length + /// byte or key leaves the iPhone invisible to every host, silently. + func testTheAdvertCarriesTxtversFirstThenTheAddress() { + let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn" + var expected = Data([9]) + Data("txtvers=1".utf8) + expected += Data([UInt8(5 + address.utf8.count)]) + Data("addr=\(address)".utf8) + XCTAssertEqual(PeerStreamFraming.txtRecord(address: address), expected) + XCTAssertEqual(PeerStreamFraming.txtRecord(address: nil), Data([9]) + Data("txtvers=1".utf8)) + XCTAssertEqual(PeerStreamFraming.txtRecord(address: String(repeating: "x", count: 251)), + Data([9]) + Data("txtvers=1".utf8), "an entry its length byte cannot say is left out") + // Python's `service_instance_name` for the same address. + XCTAssertEqual(PeerStreamFraming.instanceName(address: address), "op-b701bfc1c92768b5") + XCTAssertTrue(PeerStreamFraming.instanceName(address: nil).hasPrefix("op-")) + } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 6d1b2f38f..fc6be7c9e 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17119,6 +17119,13 @@ mod tests { rn_source_code_only("ios/WifiDirectManager.swift"), rn_source_code_only("ios/PeerStreamSession.swift"), ); + // The chapter's TXT rule: `txtvers=1` first. The encoder sits beside + // the framing, where the SwiftPM harness runs it. + assert!( + rn_source_code_only("ios/PeerStreamFraming.swift") + .contains("var entries = [\"txtvers=1\"]"), + "ios/PeerStreamFraming.swift: the TXT record must put `txtvers=1` first" + ); let kotlin = format!( "{} {}", rn_source_code_only("android/src/main/java/com/offlineprotocol/WifiDirectManager.kt"), @@ -17175,8 +17182,7 @@ mod tests { "switch PeerStreamFraming.unframe(message, \ preamble: link.preamble.awaitingPreamble)", "static let SERVICE_TYPE = \"_offlineprotocol._tcp\"", - // The chapter's TXT rule: `txtvers=1` first. - "var entries = [\"txtvers=1\"]", + "txtRecord: PeerStreamFraming.txtRecord(address: address)", ][..], ), ( diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index e44fc25b0..6aa128464 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -263,7 +263,8 @@ carrier and a manual clock, `PeerStreamReaderTests` the reader with every chunking, `PeerStreamDialPolicyTests` when to dial and redial (the ladder climbs only on a redial actually scheduled, a dial that finds no free slot waits on it, and a stale record never takes a live one's address), and `PeerStreamFramingTests` -replays the chapter's vectors. The manager's own use of Network framework +replays the chapter's vectors and pins the advert's TXT bytes and instance +name. The manager's own use of Network framework (advertise, browse, connect) is not covered in CI (C9). ## S9. Every object is built for the pod's deployment target From 280a120f1133a695d7fece910ff21305b13a43ae Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 21:01:07 +0400 Subject: [PATCH 12/16] fix(bindings): stop redialing a peer-stream record that cannot prove 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. --- .../react-native/ios/PeerStreamSession.swift | 9 ++-- .../react-native/ios/WifiDirectManager.swift | 46 ++++++++++++++++--- .../ios/tests/PeerStreamDialPolicyTests.swift | 10 ++++ crates/offline-protocol-uniffi/src/lib.rs | 18 ++++++-- docs/bridges/swift.md | 6 ++- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index 83628e322..8e8f37de5 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -367,17 +367,20 @@ struct PeerStreamDialPolicy { /// it, and with no advert the address was never dialed again. Of two /// records for one address, one in `fresh` (added or changed by this /// change) wins, being the newest; then the one in `current`, which a dial - /// may already be using. + /// may already be using. A record in `unprovable` is left out: its dial + /// was answered by a peer that did not prove the address it advertises, + /// and dialing it again would only fail the same way. static func adverts( _ records: [(address: String, endpoint: Endpoint)], fresh: Set, - current: [String: Endpoint] + current: [String: Endpoint], + unprovable: Set = [] ) -> [String: Endpoint] { func rank(_ address: String, _ endpoint: Endpoint) -> Int { fresh.contains(endpoint) ? 2 : current[address] == endpoint ? 1 : 0 } var out: [String: Endpoint] = [:] - for record in records { + for record in records where !unprovable.contains(record.endpoint) { if let kept = out[record.address], rank(record.address, kept) >= rank(record.address, record.endpoint) { continue } out[record.address] = record.endpoint diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index ad033381c..06e970e45 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -123,6 +123,12 @@ public class WifiDirectManager: NSObject, TransportManager { let reader = PeerStreamReader() let writes = WriteStallWatchdog(timeoutMs: WifiDirectManager.WRITE_STALL_MS) var queuedBytes = 0 + /// Whether the peer sent a whole frame, and whether a dialed stream + /// proved the address it was dialed toward. Heard and never proved is + /// a record that does not hold the address it advertises; a stream cut + /// mid-preamble has not been heard. + var heard = false + var proved = false init(connection: NWConnection, outbound: Bool, dialed: String?) { self.connection = connection @@ -140,6 +146,10 @@ public class WifiDirectManager: NSObject, TransportManager { private var streams = Set() /// The endpoint each advertised address is reachable at, while it is. private var adverts: [String: NWEndpoint] = [:] + /// Records whose dial was answered by a peer that did not prove the + /// address they advertise. Left out of `adverts` until the browser reports + /// the record again, so another record for the address is dialed, or none. + private var unprovable = Set() /// When to dial each advertised address, and when to dial it again. private var dialPolicy = PeerStreamDialPolicy() /// Bumped by every start() and stop(), so a timer armed for one run finds @@ -286,6 +296,7 @@ public class WifiDirectManager: NSObject, TransportManager { browser = nil streams = [] adverts = [:] + unprovable = [] dialPolicy.reset() // Report every proved peer lost while the core still holds its @@ -503,6 +514,7 @@ public class WifiDirectManager: NSObject, TransportManager { browser?.cancel() browser = nil adverts = [:] + unprovable = [] } private func browserChanged(_ newState: NWBrowser.State) { @@ -538,17 +550,14 @@ public class WifiDirectManager: NSObject, TransportManager { /// ours is skipped by its address. private func advertsChanged(_ results: Set, _ changes: Set) { guard let local = protocolInstance.localAddress() else { return } - var records: [(address: String, endpoint: NWEndpoint)] = [] - for result in results { - guard let address = Self.advertisedAddress(result), address != local else { continue } - records.append((address, result.endpoint)) - } var fresh = Set() for change in changes { if case .added(let result) = change { fresh.insert(result.endpoint) } if case .changed(_, let result, _) = change { fresh.insert(result.endpoint) } } - adverts = PeerStreamDialPolicy.adverts(records, fresh: fresh, current: adverts) + // A record reported again may now hold what it advertises. + unprovable.subtract(fresh) + recordAdverts(results, fresh: fresh, local: local) for (address, endpoint) in adverts where fresh.contains(endpoint) { // The lower address's stream is the one both ends keep, so the // lower one dials at once and the higher gives it time. @@ -561,6 +570,17 @@ public class WifiDirectManager: NSObject, TransportManager { } } + private func recordAdverts(_ results: Set, fresh: Set, local: String) { + var records: [(address: String, endpoint: NWEndpoint)] = [] + for result in results { + guard let address = Self.advertisedAddress(result), address != local else { continue } + records.append((address, result.endpoint)) + } + unprovable.formIntersection(records.map { $0.endpoint }) + adverts = PeerStreamDialPolicy.adverts( + records, fresh: fresh, current: adverts, unprovable: unprovable) + } + /// The peer address a record advertises, or nil for a record that is not /// a peer hint. A record carrying `sid` is a service instance under the /// DNS-SD mapping chapter: it names the same host and address as the peer @@ -642,6 +662,7 @@ public class WifiDirectManager: NSObject, TransportManager { for result in stream.reader.append(content) { switch result { case .success(let frame): + stream.heard = true self.peers.received(frame, from: stream) case .failure(let refusal): self.emitDiagnostic("warning", "Peer stream refused", context: ["reason": refusal.reason]) @@ -649,7 +670,8 @@ public class WifiDirectManager: NSObject, TransportManager { return } } - if let address = stream.dialed, self.peers.handle(for: address) == stream { + if !stream.proved, let address = stream.dialed, self.peers.handle(for: address) == stream { + stream.proved = true self.dialPolicy.proved(address) } } @@ -670,6 +692,16 @@ public class WifiDirectManager: NSObject, TransportManager { peers.ended(stream) stream.writes.reset() stream.connection.cancel() + // Answered by a peer that never proved the address its record + // advertises, while no stream holds it (so not a lost race): a + // device on the LAN claiming another's address, or one that moved. + // Redialing it would fail the same way on the ladder for as long as + // the record lives, and keep the real record for the address undialed. + if let address = stream.dialed, stream.heard, !stream.proved, + peers.handle(for: address) == nil, let local = protocolInstance.localAddress() { + unprovable.insert(stream.connection.endpoint) + recordAdverts(browser?.browseResults ?? [], fresh: [], local: local) + } guard let address = stream.dialed, let delay = dialPolicy.ended( address, advertised: adverts[address] != nil, diff --git a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift index efb4beedd..c4516fcaf 100644 --- a/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamDialPolicyTests.swift @@ -141,4 +141,14 @@ final class PeerStreamDialPolicyTests: XCTestCase { XCTAssertNil(policy.discovered(peer, weAreLower: true, held: false), "one dial at a time") XCTAssertEqual(policy.noSlot(peer), 2 * PeerStreamDialPolicy.redialInitialDelay) } + + /// A record that answered without proving its address gives way to + /// another for the address, or leaves it unadvertised so the ladder stops. + func testAnUnprovableRecordIsLeftOut() { + let records = [(address: peer, endpoint: "liar"), (address: peer, endpoint: "real")] + XCTAssertEqual(PeerStreamDialPolicy.adverts( + records, fresh: ["liar"], current: [peer: "liar"], unprovable: ["liar"])[peer], "real") + XCTAssertNil(PeerStreamDialPolicy.adverts( + [(address: peer, endpoint: "liar")], fresh: [], current: [:], unprovable: ["liar"])[peer]) + } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index fc6be7c9e..0e4abba83 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17018,7 +17018,8 @@ mod tests { swift.contains( "onLinkQueueSync { generation += 1 \ let old = (listener: listener, browser: browser, streams: streams) \ - listener = nil browser = nil streams = [] adverts = [:] dialPolicy.reset() \ + listener = nil browser = nil streams = [] adverts = [:] unprovable = [] \ + dialPolicy.reset() \ peers.endAll() old.listener?.cancel()" ), "ios/WifiDirectManager.swift: stop() must forget every stream before endAll()" @@ -17287,7 +17288,8 @@ mod tests { assert!( ios_manager.contains( "for result in stream.reader.append(content) { switch result { \ - case .success(let frame): self.peers.received(frame, from: stream)" + case .success(let frame): stream.heard = true \ + self.peers.received(frame, from: stream)" ), "ios/WifiDirectManager.swift: every received chunk must go through PeerStreamReader" ); @@ -17325,7 +17327,8 @@ mod tests { // by address dropped the live one's too, so the peer was never redialed. assert!( ios_manager.contains( - "adverts = PeerStreamDialPolicy.adverts(records, fresh: fresh, current: adverts)" + "adverts = PeerStreamDialPolicy.adverts( \ + records, fresh: fresh, current: adverts, unprovable: unprovable)" ) && !ios_manager.contains("adverts.removeValue(forKey:"), "ios/WifiDirectManager.swift: the browser must rebuild its adverts from the full result set" ); @@ -17345,6 +17348,15 @@ mod tests { ), "ios/WifiDirectManager.swift: a dial with no free slot must be retried on the ladder" ); + // A record whose dial was answered without proving its address is left + // out until it is reported again. Redialed, it failed the same way on + // the ladder for as long as it lived, and the real record for the + // address was never dialed. + assert!( + ios_manager.contains("unprovable.insert(stream.connection.endpoint)") + && ios_manager.contains("unprovable.subtract(fresh)"), + "ios/WifiDirectManager.swift: an unprovable record must be left out until reported again" + ); // A group client redials the owner the group has NOW. On a group // switch the new owner's dial loses the one-outbound-socket flag to diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 6aa128464..2357feea7 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -244,7 +244,11 @@ can be advertised by the record it just published and by its old one still in our cache, and removing the old one by address took the live one's address with it, so the peer was never dialed again. The instance name is a digest of the address, as on Python, so a restarted listener replaces its record -instead of adding a second. A dial with no path yet (`.waiting`) gets ten +instead of adding a second. A record whose dial is answered by a peer that +does not prove the address it advertises is left out until the browser +reports it again, so another record for the address is dialed, or none; +redialed, it failed the same way for as long as it lived and kept the real +record undialed. A dial with no path yet (`.waiting`) gets ten seconds, the connect timeout, before the redial ladder takes it over. Writes follow Android's bounds: a body is dropped (the core retries it) when From 0e418d5359108a8fa9637a0802cf82c6d6387d38 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 21:10:29 +0400 Subject: [PATCH 13/16] fix(bindings): bound an iOS peer-stream dial as a whole, not only while 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. --- .../react-native/ios/WifiDirectManager.swift | 27 ++++++++++--------- crates/offline-protocol-uniffi/src/lib.rs | 10 +++++++ docs/bridges/swift.md | 5 ++-- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 06e970e45..884972967 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -74,10 +74,11 @@ public class WifiDirectManager: NSObject, TransportManager { private static let MAX_STREAMS = PeerStreamDialPolicy.maxStreams /// A listener or browser that failed is rebuilt after this long. private static let REBUILD_DELAY: TimeInterval = 5.0 - /// How long a dialed stream may wait for a path before it is ended and - /// left to the redial ladder. The connect timeout, so AWDL gets as long to - /// come up as a connect attempt does. - private static let WAITING_GRACE: TimeInterval = 10.0 + /// How long a dial may take to become ready, whatever it waits on + /// (resolving the record, a path, AWDL coming up, the handshake), before + /// it is ended and left to the redial ladder. Python's `CONNECT_TIMEOUT` + /// and Android's connect timeout bound the whole connect the same way. + private static let DIAL_TIMEOUT: TimeInterval = 10.0 /// Android's `Limits`: queued bytes toward one peer beyond which a body /// is dropped (the core retries it), and only while that peer's oldest /// write has been outstanding for `WRITE_STALL_MS`. The core hands a burst @@ -633,15 +634,6 @@ public class WifiDirectManager: NSObject, TransportManager { case .ready: self.peers.connected(stream) self.receive(on: stream) - case .waiting: - // No path to the peer yet, which over AWDL can be the link - // still coming up. Ended if it is still waiting after the - // grace, so the redial ladder decides when to try again - // rather than a connection that waits forever. - self.linkQueue.asyncAfter(deadline: .now() + Self.WAITING_GRACE) { [weak stream] in - guard let stream = stream, case .waiting = stream.connection.state else { return } - stream.connection.cancel() - } case .failed, .cancelled: self.end(stream) default: @@ -649,6 +641,15 @@ public class WifiDirectManager: NSObject, TransportManager { } } stream.connection.start(queue: linkQueue) + guard stream.outbound else { return } + // The TCP connect timeout does not cover resolving the record, and a + // dial toward a host that left without a goodbye can sit there with no + // state change, holding a slot and its address's one dial. + linkQueue.asyncAfter(deadline: .now() + Self.DIAL_TIMEOUT) { [weak stream] in + guard let stream = stream else { return } + if case .ready = stream.connection.state { return } + stream.connection.cancel() + } } /// Reads the stream and hands each whole frame to the session, which owns diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 0e4abba83..977c1f464 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -17357,6 +17357,16 @@ mod tests { && ios_manager.contains("unprovable.subtract(fresh)"), "ios/WifiDirectManager.swift: an unprovable record must be left out until reported again" ); + // Every dial has a deadline for the whole connect. The TCP timeout does + // not cover resolving the record, so a dial toward a host that left + // could hang there, holding a slot and its address's one dial. + assert!( + ios_manager.contains( + "guard stream.outbound else { return } \ + linkQueue.asyncAfter(deadline: .now() + Self.DIAL_TIMEOUT)" + ), + "ios/WifiDirectManager.swift: every dial must be bounded as a whole" + ); // A group client redials the owner the group has NOW. On a group // switch the new owner's dial loses the one-outbound-socket flag to diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 2357feea7..5324d0982 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -248,8 +248,9 @@ instead of adding a second. A record whose dial is answered by a peer that does not prove the address it advertises is left out until the browser reports it again, so another record for the address is dialed, or none; redialed, it failed the same way for as long as it lived and kept the real -record undialed. A dial with no path yet (`.waiting`) gets ten -seconds, the connect timeout, before the redial ladder takes it over. +record undialed. A dial gets ten seconds to become ready, whatever it +waits on (resolving the record, a path, the handshake), as on Python and +Android, before the redial ladder takes it over. Writes follow Android's bounds: a body is dropped (the core retries it) when more than 4 MiB is queued toward a peer whose oldest write has been From 3eb587a0439cd55fee48cc57074b4e9a405296b2 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 21:11:15 +0400 Subject: [PATCH 14/16] fix(bindings): a peer-stream dial that lost the tie-break is not unprovable 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. --- .../react-native/ios/PeerStreamSession.swift | 7 +++++++ .../react-native/ios/WifiDirectManager.swift | 21 ++++++++++--------- .../ios/tests/PeerStreamSessionTests.swift | 17 +++++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index 8e8f37de5..ea9cfbf9d 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -108,6 +108,13 @@ final class PeerStreamSession { links.handle(for: address) } + /// The address `handle`'s preamble proved, until the stream ends, or nil. + /// Whether or not the stream went on to hold it: one refused as the + /// losing duplicate proved its address all the same. The owner's queue. + func provedAddress(of handle: Handle) -> String? { + linkStates[handle]?.preamble.address + } + /// Whether any peer has proved an address. Any thread. var isEmpty: Bool { links.isEmpty diff --git a/bindings/react-native/ios/WifiDirectManager.swift b/bindings/react-native/ios/WifiDirectManager.swift index 884972967..733999dad 100644 --- a/bindings/react-native/ios/WifiDirectManager.swift +++ b/bindings/react-native/ios/WifiDirectManager.swift @@ -665,16 +665,17 @@ public class WifiDirectManager: NSObject, TransportManager { case .success(let frame): stream.heard = true self.peers.received(frame, from: stream) + if !stream.proved, let address = stream.dialed, + self.peers.provedAddress(of: stream) == address { + stream.proved = true + self.dialPolicy.proved(address) + } case .failure(let refusal): self.emitDiagnostic("warning", "Peer stream refused", context: ["reason": refusal.reason]) stream.connection.cancel() return } } - if !stream.proved, let address = stream.dialed, self.peers.handle(for: address) == stream { - stream.proved = true - self.dialPolicy.proved(address) - } } if isComplete || error != nil { stream.connection.cancel() @@ -694,12 +695,12 @@ public class WifiDirectManager: NSObject, TransportManager { stream.writes.reset() stream.connection.cancel() // Answered by a peer that never proved the address its record - // advertises, while no stream holds it (so not a lost race): a - // device on the LAN claiming another's address, or one that moved. - // Redialing it would fail the same way on the ladder for as long as - // the record lives, and keep the real record for the address undialed. - if let address = stream.dialed, stream.heard, !stream.proved, - peers.handle(for: address) == nil, let local = protocolInstance.localAddress() { + // advertises: a device on the LAN claiming another's address, or one + // that moved. Redialing it would fail the same way on the ladder for + // as long as the record lives, and keep the real record for the + // address undialed. A dial that lost the tie-break did prove it. + if stream.dialed != nil, stream.heard, !stream.proved, + let local = protocolInstance.localAddress() { unprovable.insert(stream.connection.endpoint) recordAdverts(browser?.browseResults ?? [], fresh: [], local: local) } diff --git a/bindings/react-native/ios/tests/PeerStreamSessionTests.swift b/bindings/react-native/ios/tests/PeerStreamSessionTests.swift index 2bcdd6d55..909087420 100644 --- a/bindings/react-native/ios/tests/PeerStreamSessionTests.swift +++ b/bindings/react-native/ios/tests/PeerStreamSessionTests.swift @@ -317,6 +317,23 @@ final class PeerStreamSessionTests: XCTestCase { XCTAssertEqual(disconnected, ["in"], "the held stream proved itself; no deadline fires") } + /// What a stream's preamble proved, which the manager uses to tell a + /// record that cannot prove its address from a dial that lost the + /// tie-break. + func testProvedAddressIsWhatThePreambleProvedNotWhatTheStreamHolds() { + prove("out", as: "peer-b") + prove("in", as: "peer-b") + XCTAssertEqual(disconnected, ["in"], "the losing duplicate") + XCTAssertEqual(session.provedAddress(of: "in"), "peer-b") + + session.claim("peer-c", for: "liar") + prove("liar", as: "peer-d") + XCTAssertNil(session.provedAddress(of: "liar")) + + session.ended("in") + XCTAssertNil(session.provedAddress(of: "in"), "forgotten with the stream") + } + func testEndAllReportsEachAnnouncedPeerOnce() { prove("h1", as: "peer-b") prove("h2", as: "peer-c") From 6af7c33b560ae844a4d0d2a6aac5f59fe6238fc7 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 21:11:54 +0400 Subject: [PATCH 15/16] docs(bindings): the iOS inbound bound counts addresses, and the reserve 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. --- CHANGELOG.md | 10 +++++----- bindings/react-native/ios/PeerStreamSession.swift | 15 +++++++++------ ...027-ios-peer-streams-ride-network-framework.md | 6 ++++-- docs/bridges/swift.md | 13 ++++++++----- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60ddd0d6..9057548dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -583,11 +583,11 @@ archived by series under [docs/changelog/](docs/changelog/); see the `NSLocalNetworkUsageDescription` is still required. A denied local-network permission is now reported as an `error` diagnostic instead of failing silently. The seven-peer cap Multipeer imposed is gone; at most sixteen - streams are open, twelve of them inbound and four from any one host. The - hop is plain TCP, as on Android and Python, where - Multipeer encrypted it; payloads are sealed either way. Both ends of a pair - now dial, and of two streams for one address both keep the one the lower - address opened, the Python manager's rule + streams are open, twelve of them inbound and four from any one address. The + hop is plain TCP, as on Android and Python, where Multipeer encrypted it; + payloads are sealed either way. Both ends of a pair now dial, and of two + streams for one address both keep the one the lower address opened, the + Python manager's rule ([ADR 0027](docs/adr/0027-ios-peer-streams-ride-network-framework.md)). The podspec links `Network` in place of `MultipeerConnectivity`. This replaces the unreleased Multipeer service-type change, which never diff --git a/bindings/react-native/ios/PeerStreamSession.swift b/bindings/react-native/ios/PeerStreamSession.swift index ea9cfbf9d..0066d12f5 100644 --- a/bindings/react-native/ios/PeerStreamSession.swift +++ b/bindings/react-native/ios/PeerStreamSession.swift @@ -303,12 +303,15 @@ struct PeerStreamDialPolicy { /// listener full of strangers' sockets never stops this device reaching /// the peers it browses. static let maxInbound = 12 - /// Inbound streams one remote host may hold, proved or not. A host needs - /// one, two while it reconnects past its own stale stream. Without the - /// bound one machine on the LAN fills the inbound share alone, with silent - /// sockets that each hold a slot for the preamble deadline, or with as - /// many self-made identities. The Python manager's `MAX_STREAMS_PER_HOST`, - /// scaled to this budget. + /// Inbound streams one remote address may hold, proved or not. A host + /// needs one, two while it reconnects past its own stale stream. Without + /// the bound one address on the LAN fills the inbound share alone, with + /// silent sockets that each hold a slot for the preamble deadline, or with + /// as many self-made identities. The Python manager's + /// `MAX_STREAMS_PER_HOST`, scaled to this budget. It counts addresses, + /// not machines: one with many IPv6 addresses is many hosts here. What + /// keeps dials possible whatever the listener holds is `maxInbound`'s + /// reserve, so raising `maxInbound` to `maxStreams` gives that up. static let maxInboundPerHost = 4 /// Addresses with a dial scheduled or an outbound stream open. diff --git a/docs/adr/0027-ios-peer-streams-ride-network-framework.md b/docs/adr/0027-ios-peer-streams-ride-network-framework.md index 0af497638..0fc6ab32d 100644 --- a/docs/adr/0027-ios-peer-streams-ride-network-framework.md +++ b/docs/adr/0027-ios-peer-streams-ride-network-framework.md @@ -96,8 +96,10 @@ seconds idle, 5 between probes, 3 probes. Android still do not talk directly. - The stream budget is 16 open streams, as on Android, instead of seven. Unlike a Wi-Fi Direct group, the listener is open to the whole LAN, so at - most 12 are inbound and at most 4 come from one remote host, as the Python - manager bounds its own. + most 12 are inbound, leaving 4 for dials whatever the listener holds, and + at most 4 come from one remote address, as the Python manager bounds its + own. One machine with many addresses can still fill the inbound share; the + reserve is what keeps it from stopping this device's dials. - The first contact between two iPhones may open two streams if the lower one's dial takes longer than five seconds. One closes without a report, which the chapter's one-stream-per-address rule already covers. diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md index 5324d0982..4c614041f 100644 --- a/docs/bridges/swift.md +++ b/docs/bridges/swift.md @@ -256,11 +256,14 @@ Writes follow Android's bounds: a body is dropped (the core retries it) when more than 4 MiB is queued toward a peer whose oldest write has been outstanding for two seconds, and a write outstanding for thirty seconds ends the stream. Every stream carries TCP keepalive with the Python manager's -timers. At most sixteen streams are open, at most twelve of them inbound so -the listener never takes the slots a dial needs, and at most four inbound -from one remote host, the Python manager's per-host bound scaled to this -budget: the listener is open to the whole LAN, and without it one machine's -silent sockets hold every slot. A local-network denial is reported +timers. At most sixteen streams are open, and at most twelve of them +inbound: the listener is open to the whole LAN, and the four left over are +what keeps this device able to dial whatever strangers' sockets hold the +rest. At most four inbound streams come from one remote address, the Python +manager's per-host bound scaled to this budget, so one address cannot hold +the whole inbound share. The bound counts addresses, not machines (one with +many IPv6 addresses is many), so it limits the easy case, and the reserve is +the guarantee. A local-network denial is reported as a diagnostic naming `NSBonjourServices`. `PeerStreamSessionTests` drives the session with string handles, a fake From 2d3507a7620dd620cda248682e64f6e4b6de58cd Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Thu, 1 Oct 2026 22:08:00 +0400 Subject: [PATCH 16/16] fix(bindings): regenerate the Python local API table after the UDL comment change --- bindings/python/offline_protocol_sdk/local_api/table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/python/offline_protocol_sdk/local_api/table.py b/bindings/python/offline_protocol_sdk/local_api/table.py index 3d024bc3b..26a2037d4 100644 --- a/bindings/python/offline_protocol_sdk/local_api/table.py +++ b/bindings/python/offline_protocol_sdk/local_api/table.py @@ -9,7 +9,7 @@ from __future__ import annotations -UDL_SHA256 = "cff2c11d29e6149e3d9c04919c03992fd2c1f760ecf20af1a6054e796851661e" +UDL_SHA256 = "db1022f43254c9843ac4164e4993169e1d4886901be23f1340d6aed5652e43bc" TABLE = {'callbacks': ('MlsStorageProvider', 'ProtocolStateStorageProvider',