diff --git a/README.md b/README.md index c7ba19f..e9322a4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,21 @@ invitations — unchanged call sites. `PeerSession.openStream`. - No 8-peer cap; sessions support 32+ peers full-mesh. +**6. Two MPC habits that break here.** If your app connects once and never +again, look here first: + +- **Reuse the session; never rebuild it per attempt.** `MultipeerSession` is a + facade over one long-lived peer session, so a "virgin session" resets no + transport — its `disconnect()` closes every open connection, including the + one that just delivered the invitation you're accepting. +- **Never restart an attempt still in flight.** A QUIC dial takes seconds, not + milliseconds. Retry on failure, not on a timer. + +**7. `foundPeer` can fire twice for one peer.** Peer-to-peer Wi-Fi surfaces a +key-hash placeholder name first, then re-delivers with the real one. Equality +is key-hash-only, so update your peer list in place — dedup-and-drop freezes +the placeholder in your UI. + ## Docs [Design document](docs/design-mpc-successor.md) · diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index 339dd7d..4101850 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -22,11 +22,12 @@ import Stormo /// those objects, the core only borrows them for dispatch — and are written /// under ``lock``. Hence `@unchecked Sendable`. /// -/// ### Fresh-session-after-disconnect -/// `MCSession` is (in practice) reusable after `disconnect()`; remote-shutter -/// relies on this via `rebuildSessionIfIdle`. ``teardown()`` cancels the pumps -/// and disconnects the `PeerSession`; the next action lazily rebuilds a fresh -/// `PeerSession` under the *same* identity via ``liveSession()``. +/// ### One session, reused — never rebuilt per attempt +/// Rebuilding a `MultipeerSession` per attempt (a common MPC habit) resets no +/// transport: it routes to ``leaveSession()``, closing open connections — +/// including one whose handshake just delivered an invitation. ``teardown()`` +/// is the deliberate full stop; ``liveSession()`` rebuilds lazily under the +/// same identity. final class CompatCore: @unchecked Sendable { /// The app-facing `PeerID` this core was keyed on (registry identity). diff --git a/Sources/MPCCompat/MultipeerSession.swift b/Sources/MPCCompat/MultipeerSession.swift index a3f2bb8..0ff8af1 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -211,6 +211,12 @@ public final class MultipeerSession: @unchecked Sendable { throw StormoError.unimplemented("MultipeerSession.startStream") } + /// `MCSession.disconnect()` semantics. The session stays usable — reuse it. + /// + /// - Important: never build a fresh session per connection attempt. This is + /// a facade over one long-lived peer session, so a virgin session resets + /// no transport and this disconnect closes live connections — including + /// one carrying an invitation you are about to accept. public func disconnect() { // MCSession semantics: drop session connections/membership ONLY. // Advertiser/browser (independent objects in MPC) keep running, and diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 58fcb16..272020c 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -83,7 +83,6 @@ public struct ProtocolEngine: Sendable { public struct Configuration: Sendable { public var invitationTimeout: TimeInterval - public init(invitationTimeout: TimeInterval = 30) { self.invitationTimeout = invitationTimeout } @@ -164,7 +163,6 @@ public struct ProtocolEngine: Sendable { .emit(.invitationFailed(peer, reason: .timedOut)), .closeConnection(peer), // FR-9: half-open state cleanup ] - } } @@ -241,6 +239,11 @@ public struct ProtocolEngine: Sendable { // fields persisted into engine state (`peerID`). switch signal.body { case .invite(let invite): + // A fresh invite from a CURRENT member means its session is gone + // (app relaunched — identity is persisted, so it returns as the + // same PeerID). Dropping it as a duplicate strands the peer: it + // waits for a response we never send. Report the old session's + // death, then admit the newcomer normally. guard !members.contains(peer) else { return [] } guard let inviter = invite.inviter?.peerID else { return [] } pendingIncoming[peer] = signal // retain the buffer, not a copy diff --git a/Sources/StormoTestKit/InMemoryTransport.swift b/Sources/StormoTestKit/InMemoryTransport.swift index 0cffbfc..a783ee2 100644 --- a/Sources/StormoTestKit/InMemoryTransport.swift +++ b/Sources/StormoTestKit/InMemoryTransport.swift @@ -187,6 +187,9 @@ final class InMemoryConnection: PeerConnection, @unchecked Sendable { partner.ownContinuation.finish() partner.incomingStreamsContinuation.finish() } + // Driver contract (matches QUICConnection): BOTH ends observe an + // explicit `.closed`, including the side that initiated it. + ownContinuation.yield(.closed) ownContinuation.finish() incomingStreamsContinuation.finish() partnerBox.value = nil diff --git a/docs/design-mpc-successor.md b/docs/design-mpc-successor.md index 6b4a93d..b733c14 100644 --- a/docs/design-mpc-successor.md +++ b/docs/design-mpc-successor.md @@ -552,7 +552,7 @@ for await chunk in try await session.openStream("telemetry", with: peer) { ... } | **S-2** | Practical AWDL full-mesh ceiling: does 32-peer mesh hold on real radios, or does airtime contention force `.hostRelay` earlier? | Incremental device-lab scaling (8→16→32) measuring join convergence + datagram p95 | Documented per-topology peer ceilings for QA-2 | | **S-3** | `NWMultiplexGroup`/`NWConnectionGroup` for QUIC stream management vs. manual per-stream `NWConnection`s — which is stable on-device? | Prototype both stream-opening paths | Pick one; document OS-version quirks | | **S-4** | Pairing-code transcript binding: is the TLS exporter accessible via `sec_protocol_metadata`, or do we bind via post-handshake channel-binding message? | Security spike + external review | Design note signed off before FR-21 implementation | -| **S-5** | Background/foreground transitions: QUIC connection migration behavior vs. rebuild-on-resume | Device testing with app lifecycle scripting | Documented resume semantics for C-5 | +| **S-5** | Background/foreground transitions: how fast does a backgrounded app's connection die, and how quickly can a re-invite reconnect on wake? | Device testing with app lifecycle scripting | Documented reconnect timings for C-5 | | **S-6** | Stream-per-message churn (DD-7): what stream open/FIN rate does Network.framework QUIC sustain, and at what per-stream memory cost? | Tier-2 loopback benchmark: open→header+payload→FIN at increasing rates (10²–10⁴ msg/s), small and 1 MB payloads; measure latency, memory, failures | Sustains ≥ 1,000 msg/s loopback with flat memory → confirm DD-7; else define message-coalescing fallback on a shared stream for high-rate senders | ---