From 25866e9b2c498229d4389dca1fe0e4d6659163f7 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 21:39:37 -0700 Subject: [PATCH 01/11] Suspension grace (C-5): backgrounded peers suspend and resume, not vanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS suspension freezes the app's QUIC PINGs, so peers dropped ~5 s after every backgrounding (1 s keepalive / 5 s idle, QUICTLS) — MC survived this because system daemons owned its links; a userspace transport cannot. Implements the C-5 design: suspension/resume made explicit. Wire: new `Suspend { grace_ms }` signal (append-only union add). Engine (sans-I/O, tier-1 tested): a member that announced suspension survives its connection loss for a grace window — no `peerLeft` — with a `.suspension(peer)` timer turning expiry into a normal departure. Reconnect within grace cancels the timer and resumes silently (membership never lapses). Grace requests clamp to a configurable maximum (120 s default) so a remote cannot park as a zombie member. Expiry never evicts a member whose link is alive (short backgrounds can end without the connection ever dropping, and the observer side has no resume trigger — only the timer). Local side: `.suspend(grace)` marks all members and announces to connected ones; `.resume` re-dials members with dead links via retained endpoints and sheds marks on live ones. Runtime: `PeerSession.announceSuspension(gracePeriod:)` / `resume()`; new `MembershipEvent.suspended`/`.resumed`. MPCCompat: a suspended peer simply STAYS `.connected` (MC has no suspended state); grace expiry arrives as the normal `.notConnected`. `MultipeerSession.announceSuspension()`/`resumeFromSuspension()` for apps to call from didEnterBackground/didBecomeActive. Test-double contract fix the tier-2 tests caught: InMemoryConnection's `close()` notified only the partner; the closing side's stream just finished, so the killer's own session bookkeeping still counted the link as alive and `resume()` re-dialed nothing. The real QUIC driver yields `.closed` on both ends — the double now does too. 11 tier-1 engine tests + 2 tier-2 runtime tests (silent kill via KillSwitchTransport → hold → resume-with-traffic / expiry). Suite: 89 tests / 21 suites green. Co-Authored-By: Claude Fable 5 --- Schemas/signal.fbs | 9 + Sources/MPCCompat/CompatCore.swift | 16 ++ Sources/MPCCompat/MultipeerSession.swift | 17 ++ Sources/Stormo/Events.swift | 10 +- Sources/Stormo/PeerSession.swift | 25 +++ .../Generated/signal_generated.swift | 44 ++++- Sources/StormoProtocol/ProtocolEngine.swift | 102 +++++++++- Sources/StormoProtocol/Signal.swift | 10 + Sources/StormoTestKit/InMemoryTransport.swift | 5 + .../StormoProtocolTests/SuspensionTests.swift | 182 ++++++++++++++++++ .../StormoTests/SuspensionRuntimeTests.swift | 131 +++++++++++++ 11 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 Tests/StormoProtocolTests/SuspensionTests.swift create mode 100644 Tests/StormoTests/SuspensionRuntimeTests.swift diff --git a/Schemas/signal.fbs b/Schemas/signal.fbs index 9cb96d6..659cf3a 100644 --- a/Schemas/signal.fbs +++ b/Schemas/signal.fbs @@ -67,6 +67,14 @@ table StreamOpen { label:string (id: 0); } +/// The sender is about to be suspended (iOS backgrounding, C-5). Receivers +/// treat the connection loss that follows as a suspension, not a departure, +/// for up to `grace_ms` (clamped by the receiver's configuration): membership +/// survives, and a reconnect within the grace resumes the peer silently. +table Suspend { + grace_ms:ulong (id: 0); +} + union SignalBody { Invite, InviteResponse, @@ -74,6 +82,7 @@ union SignalBody { RosterUpdate, TransferOffer, StreamOpen, + Suspend, } /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index 339dd7d..88f1aa0 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -197,6 +197,12 @@ final class CompatCore: @unchecked Sendable { emitState(peer: member.id, state: .connected) case .left(let id), .unreachable(let id): emitState(peer: id, state: .notConnected) + case .suspended, .resumed: + // MC has no suspended state: a peer under its background grace + // window (C-5) simply STAYS `.connected` — grace expiry arrives + // as the normal `.left` → `.notConnected`, and a resume within + // grace is invisible (membership never lapsed). + break case .identityChanged: // TOFU continuity warning (FR-21) has no MCSession analog; ignore. break @@ -295,6 +301,16 @@ final class CompatCore: @unchecked Sendable { enqueueOp { await session.stopBrowsing() } } + func announceSuspension(gracePeriod: TimeInterval) { + guard let session = currentSession() else { return } + enqueueOp { await session.announceSuspension(gracePeriod: gracePeriod) } + } + + func resumeSession() { + guard let session = currentSession() else { return } + enqueueOp { await session.resume() } + } + func invite(peerID: PeerID, context: Data?, timeout: TimeInterval) { let session = liveSession() lock.lock() diff --git a/Sources/MPCCompat/MultipeerSession.swift b/Sources/MPCCompat/MultipeerSession.swift index a3f2bb8..dae13ef 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -223,6 +223,23 @@ public final class MultipeerSession: @unchecked Sendable { _connectedPeers.removeAll() stateLock.unlock() } + + // MARK: Background suspension (C-5 — no MCSession analog) + + /// Announce that this app is about to be suspended (call from + /// `didEnterBackground`). Connected peers keep this device `.connected` + /// for the grace period instead of dropping it ~5 s after the freeze; + /// pair with ``resumeFromSuspension()`` on foreground. Beyond-MC API: MC + /// survived backgrounding at the OS level, a userspace transport cannot. + public func announceSuspension(gracePeriod: TimeInterval = 60) { + core?.announceSuspension(gracePeriod: gracePeriod) + } + + /// Re-establish connections suspended while the app was frozen (call + /// from `didBecomeActive`). + public func resumeFromSuspension() { + core?.resumeSession() + } } extension PeerID { diff --git a/Sources/Stormo/Events.swift b/Sources/Stormo/Events.swift index 43ac505..87ff303 100644 --- a/Sources/Stormo/Events.swift +++ b/Sources/Stormo/Events.swift @@ -52,11 +52,19 @@ public enum DiscoveryEvent: Sendable { case lost(PeerID) } -/// Session membership events (FR-10, FR-13, FR-14). +/// Session membership events (FR-10, FR-13, FR-14, C-5). public enum MembershipEvent: Sendable { case joined(SessionPeer) case left(PeerID) case unreachable(PeerID) + /// The member announced suspension (iOS backgrounding, C-5). It is STILL + /// a member: its connection loss is expected, and either `.resumed` + /// follows (reconnect within the grace window) or `.left` does (grace + /// expired). + case suspended(PeerID) + /// A suspended member reconnected within its grace window. Membership + /// never lapsed — no `.left`/`.joined` pair is emitted around it. + case resumed(SessionPeer) /// A previously known peer reconnected with a different key (TrustPolicy.automatic /// continuity warning, FR-21). case identityChanged(PeerID, previousKeyHash: Data) diff --git a/Sources/Stormo/PeerSession.swift b/Sources/Stormo/PeerSession.swift index b366e14..ec65136 100644 --- a/Sources/Stormo/PeerSession.swift +++ b/Sources/Stormo/PeerSession.swift @@ -235,6 +235,25 @@ public actor PeerSession { /// Currently admitted session members (excluding the local peer). public var members: Set { engine.members } + // MARK: Suspension (C-5 — iOS backgrounding) + + /// Announce to every connected member that this app is about to be + /// suspended (call from `didEnterBackground`). Members treat the + /// connection loss that follows as a suspension — membership survives for + /// `gracePeriod` (each side clamps to its configured maximum) — instead + /// of an immediate departure. Pair with ``resume()`` on foreground. + public func announceSuspension(gracePeriod: TimeInterval = 60) async { + run(.command(.suspend(grace: gracePeriod))) + } + + /// Re-establish connections to members suspended while this app was + /// frozen (call from `didBecomeActive`). Members whose links survived + /// simply shed their suspension; the rest are re-dialed via their + /// retained endpoints, falling back to the grace timer on failure. + public func resume() async { + run(.command(.resume)) + } + // MARK: Lifecycle /// Leave the session — close all peer connections and clear membership — @@ -365,6 +384,12 @@ public actor PeerSession { case .peerLeft(let peer): membershipContinuation.yield(.left(peer)) + case .peerSuspended(let peer): + membershipContinuation.yield(.suspended(peer)) + + case .peerResumed(let peer): + membershipContinuation.yield(.resumed(SessionPeer(id: peer))) + case .invitationFailed(let peer, let reason): let error: StormoError switch reason { diff --git a/Sources/StormoProtocol/Generated/signal_generated.swift b/Sources/StormoProtocol/Generated/signal_generated.swift index b7f2342..1cf223e 100644 --- a/Sources/StormoProtocol/Generated/signal_generated.swift +++ b/Sources/StormoProtocol/Generated/signal_generated.swift @@ -20,8 +20,9 @@ public enum Stormo_Wire_SignalBody: UInt8, UnionEnum { case rosterupdate = 4 case transferoffer = 5 case streamopen = 6 + case suspend = 7 - public static var max: Stormo_Wire_SignalBody { return .streamopen } + public static var max: Stormo_Wire_SignalBody { return .suspend } public static var min: Stormo_Wire_SignalBody { return .none_ } } @@ -340,6 +341,45 @@ public struct Stormo_Wire_StreamOpen: FlatBufferObject, Verifiable { } } +/// The sender is about to be suspended (iOS backgrounding, C-5). Receivers +/// treat the connection loss that follows as a suspension, not a departure, +/// for up to `grace_ms` (clamped by the receiver's configuration): membership +/// survives, and a reconnect within the grace resumes the peer silently. +public struct Stormo_Wire_Suspend: FlatBufferObject, Verifiable { + + static func validateVersion() { FlatBuffersVersion_25_2_10() } + public var __buffer: ByteBuffer! { return _accessor.bb } + private var _accessor: Table + + private init(_ t: Table) { _accessor = t } + public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } + + private enum VTOFFSET: VOffset { + case graceMs = 4 + var v: Int32 { Int32(self.rawValue) } + var p: VOffset { self.rawValue } + } + + public var graceMs: UInt64 { let o = _accessor.offset(VTOFFSET.graceMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } + public static func startSuspend(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 1) } + public static func add(graceMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: graceMs, def: 0, at: VTOFFSET.graceMs.p) } + public static func endSuspend(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + public static func createSuspend( + _ fbb: inout FlatBufferBuilder, + graceMs: UInt64 = 0 + ) -> Offset { + let __start = Stormo_Wire_Suspend.startSuspend(&fbb) + Stormo_Wire_Suspend.add(graceMs: graceMs, &fbb) + return Stormo_Wire_Suspend.endSuspend(&fbb, start: __start) + } + + public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { + var _v = try verifier.visitTable(at: position) + try _v.visit(field: VTOFFSET.graceMs.p, fieldName: "graceMs", required: false, type: UInt64.self) + _v.finish() + } +} + /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on /// the wire. Unknown body variants are ignored-and-logged, never fatal (QA-11). public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { @@ -393,6 +433,8 @@ public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_TransferOffer.self) case .streamopen: try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_StreamOpen.self) + case .suspend: + try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_Suspend.self) } }) _v.finish() diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 58fcb16..1499556 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -36,6 +36,13 @@ public struct ProtocolEngine: Sendable { case respondToInvitation(from: PeerID, accept: Bool) case send(Data, to: Recipients, delivery: Delivery) case leave + /// The local app is about to be suspended (iOS backgrounding, C-5): + /// announce it to every connected member and treat their inevitable + /// connection losses as suspensions — not departures — for `grace`. + case suspend(grace: TimeInterval) + /// The local app returned to the foreground: re-dial every suspended + /// member whose connection died while we were frozen. + case resume } // MARK: Effects @@ -56,6 +63,9 @@ public struct ProtocolEngine: Sendable { public enum TimerKey: Hashable, Sendable { case invitation(PeerID) + /// Suspension grace window (C-5): expiry turns a suspended member into + /// a departed one. + case suspension(PeerID) } public enum Event: Sendable, Equatable { @@ -63,6 +73,13 @@ public struct ProtocolEngine: Sendable { case invitationFailed(PeerID, reason: InvitationFailure) case peerJoined(PeerID) case peerLeft(PeerID) + /// A member announced suspension (backgrounded, C-5). It is still a + /// member; its connection loss is expected and does not emit `peerLeft` + /// until the grace window expires. + case peerSuspended(PeerID) + /// A suspended member reconnected within its grace window. Membership + /// never lapsed. + case peerResumed(PeerID) case messageReceived(Data, from: PeerID, delivery: Delivery) /// A member announced a resource transfer on the control stream (FR-17). /// The runtime matches the paired `transferChunk` stream by `id` and @@ -83,9 +100,14 @@ public struct ProtocolEngine: Sendable { public struct Configuration: Sendable { public var invitationTimeout: TimeInterval + /// Ceiling on the suspension grace a peer may request (C-5): a remote + /// peer must not be able to park itself as a zombie member forever. + public var maxSuspensionGrace: TimeInterval - public init(invitationTimeout: TimeInterval = 30) { + public init(invitationTimeout: TimeInterval = 30, + maxSuspensionGrace: TimeInterval = 120) { self.invitationTimeout = invitationTimeout + self.maxSuspensionGrace = maxSuspensionGrace } } @@ -105,6 +127,10 @@ public struct ProtocolEngine: Sendable { // Zero-copy (DD-5/DD-6): retain the verified Signal (≤64 KB buffer) rather // than copying fields out of it. private var pendingIncoming: [PeerID: Signal] = [:] + // Members under a suspension grace window (C-5): their connection loss is + // expected and keeps membership; cleared by resume (connectionEstablished) + // or by the suspension timer turning them into departures. + private var suspended: Set = [] public init(localPeer: PeerID, configuration: Configuration = Configuration()) { self.localPeer = localPeer @@ -127,6 +153,14 @@ public struct ProtocolEngine: Sendable { case .connectionEstablished(let peer): connections.insert(peer) + // A suspended member reconnecting within its grace window resumes + // silently (C-5): membership never lapsed. + if suspended.remove(peer) != nil { + return [ + .cancelTimer(.suspension(peer)), + .emit(.peerResumed(peer)), + ] + } // If we initiated for a pending invitation, send it now (FR-8: the // invite travels only over the secured connection). The invitation // timer was armed when the invite command was issued — it covers @@ -145,7 +179,10 @@ public struct ProtocolEngine: Sendable { effects.append(.emit(.invitationFailed(peer, reason: .connectionLost))) } pendingIncoming.removeValue(forKey: peer) - if members.remove(peer) != nil { + if suspended.contains(peer) { + // Expected loss (C-5): the member is suspended, membership + // survives until the suspension timer expires or it resumes. + } else if members.remove(peer) != nil { // FR-14: one peer's departure never disturbs the rest. effects.append(.emit(.peerLeft(peer))) } @@ -165,6 +202,16 @@ public struct ProtocolEngine: Sendable { .closeConnection(peer), // FR-9: half-open state cleanup ] + case .timerFired(.suspension(let peer)): + // Grace expired without a resume: the suspension becomes a + // departure (C-5). A no-op if the peer resumed or we left — and + // never a departure while the connection is alive (a short + // background can end without the link ever dropping; the other + // side gets no resume trigger, only this timer). + guard suspended.remove(peer) != nil else { return [] } + if connections.contains(peer) { return [] } + guard members.remove(peer) != nil else { return [] } + return [.emit(.peerLeft(peer))] } } @@ -228,9 +275,48 @@ public struct ProtocolEngine: Sendable { connections.removeAll() pendingOutgoing.removeAll() pendingIncoming.removeAll() + suspended.removeAll() // stale suspension timers no-op on fire return open .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } .map { .closeConnection($0) } + + case .suspend(let grace): + // Announce to every connected member, and mark ALL members + // suspended locally: our own connection-closed inputs (observed + // now or queued until we thaw) must not evict them before + // `.resume` gets a chance to re-dial. Local timers freeze with + // the process, so effectively the grace runs from our wake-up. + let duration = min(grace, configuration.maxSuspensionGrace) + let signal = Signal.suspend(graceMs: UInt64(duration * 1000)) + var effects: [Effect] = [] + for member in members.sorted(by: { $0.keyHash.lexicographicallyPrecedes($1.keyHash) }) { + suspended.insert(member) + if connections.contains(member) { + effects.append(.sendSignal(signal, to: member)) + } + effects.append(.startTimer(.suspension(member), duration: duration)) + } + return effects + + case .resume: + // Re-dial every suspended member whose connection died while we + // were frozen; a member whose link survived just sheds its + // suspension. Marks on dead-link members stay until + // `connectionEstablished` resumes them — a failed re-dial falls + // back to the grace timer. + var effects: [Effect] = [] + let marked = members + .filter(suspended.contains) + .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } + for member in marked { + if connections.contains(member) { + suspended.remove(member) + effects.append(.cancelTimer(.suspension(member))) + } else { + effects.append(.connect(to: member)) + } + } + return effects } } @@ -300,6 +386,18 @@ public struct ProtocolEngine: Sendable { guard let label = open.label else { return [] } return [.emit(.streamOpened(label: label, from: peer))] + case .suspend(let suspend): + // Membership-gate (DD-6), and clamp the requested grace: a remote + // must not park itself as a zombie member indefinitely. + guard members.contains(peer) else { return [] } + let duration = min( + TimeInterval(suspend.graceMs) / 1000, configuration.maxSuspensionGrace) + suspended.insert(peer) + return [ + .startTimer(.suspension(peer), duration: duration), + .emit(.peerSuspended(peer)), + ] + case .unrecognized: // Forward compatibility (QA-11, DD-5 rule 1): ignore-and-log. return [] diff --git a/Sources/StormoProtocol/Signal.swift b/Sources/StormoProtocol/Signal.swift index b1680c7..e6f4758 100644 --- a/Sources/StormoProtocol/Signal.swift +++ b/Sources/StormoProtocol/Signal.swift @@ -11,6 +11,7 @@ public typealias WireCodeConfirm = Stormo_Wire_CodeConfirm public typealias WireRosterUpdate = Stormo_Wire_RosterUpdate public typealias WireTransferOffer = Stormo_Wire_TransferOffer public typealias WireStreamOpen = Stormo_Wire_StreamOpen +public typealias WireSuspend = Stormo_Wire_Suspend public typealias WireTransferId = Stormo_Wire_TransferId /// A control-plane message: a verified FlatBuffers buffer read **in place** @@ -62,6 +63,7 @@ public struct Signal: @unchecked Sendable, Equatable { case rosterUpdate(WireRosterUpdate) case transferOffer(WireTransferOffer) case streamOpen(WireStreamOpen) + case suspend(WireSuspend) /// Absent or unrecognized union variant (forward compatibility, QA-11): /// ignored-and-logged, never fatal (DD-5 rule 1). case unrecognized @@ -81,6 +83,8 @@ public struct Signal: @unchecked Sendable, Equatable { return root.body(type: WireTransferOffer.self).map(Body.transferOffer) ?? .unrecognized case .streamopen: return root.body(type: WireStreamOpen.self).map(Body.streamOpen) ?? .unrecognized + case .suspend: + return root.body(type: WireSuspend.self).map(Body.suspend) ?? .unrecognized case .none_: return .unrecognized } @@ -136,6 +140,12 @@ public struct Signal: @unchecked Sendable, Equatable { } } + public static func suspend(graceMs: UInt64) -> Signal { + build(.suspend) { fbb in + WireSuspend.createSuspend(&fbb, graceMs: graceMs) + } + } + private static func build( _ bodyType: WireSignalBody, _ makeBody: (inout FlatBufferBuilder) -> Offset diff --git a/Sources/StormoTestKit/InMemoryTransport.swift b/Sources/StormoTestKit/InMemoryTransport.swift index 0cffbfc..cfa87bd 100644 --- a/Sources/StormoTestKit/InMemoryTransport.swift +++ b/Sources/StormoTestKit/InMemoryTransport.swift @@ -187,6 +187,11 @@ final class InMemoryConnection: PeerConnection, @unchecked Sendable { partner.ownContinuation.finish() partner.incomingStreamsContinuation.finish() } + // Driver contract (matches QUICConnection): BOTH ends observe an + // explicit `.closed` event, including the side that initiated the + // close — a transport-level kill must update the killer's own session + // bookkeeping too, or a later resume re-dial thinks the link is alive. + ownContinuation.yield(.closed) ownContinuation.finish() incomingStreamsContinuation.finish() partnerBox.value = nil diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift new file mode 100644 index 0000000..b222ea3 --- /dev/null +++ b/Tests/StormoProtocolTests/SuspensionTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing + +@testable import StormoProtocol + +/// Tier-1 tests (DD-6) for the suspension grace protocol (C-5): iOS +/// backgrounding kills QUIC connections in ~5 s, so a peer that announced +/// `Suspend` must be treated as suspended — not departed — until its grace +/// window expires or it reconnects. Time is an input: the tests fire timers. +@Suite("ProtocolEngine — suspension grace (C-5)") +struct SuspensionTests { + + static func makePeer(_ name: String, byte: UInt8) -> PeerID { + PeerID( + keyHash: Data([0x12, 0x20]) + Data(repeating: byte, count: 32), + displayName: name) + } + + let alice = Self.makePeer("Alice", byte: 0x0A) + let bob = Self.makePeer("Bob", byte: 0x0B) + + /// Drive `peer` through connect+invite+accept (mirrors the tier-1 helper). + private func engineWithMember(_ local: PeerID, member: PeerID) -> ProtocolEngine { + var engine = ProtocolEngine(localPeer: local) + _ = engine.handle(.connectionEstablished(member)) + _ = engine.handle(.signal(.invite(inviter: member, context: nil), from: member)) + _ = engine.handle(.command(.respondToInvitation(from: member, accept: true))) + return engine + } + + // MARK: The spec repro — without Suspend, connectionClosed = peerLeft + + @Test("Suspend notice: the following connection loss is NOT a departure") + func suspendedCloseKeepsMembership() { + var engine = engineWithMember(alice, member: bob) + + let noticed = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + #expect(noticed == [ + .startTimer(.suspension(bob), duration: 30), + .emit(.peerSuspended(bob)), + ]) + + // The ~5 s QUIC idle timeout closes the connection. Suspended member: + // no peerLeft, membership intact. + let closed = engine.handle(.connectionClosed(bob)) + #expect(closed == []) + #expect(engine.members.contains(bob)) + } + + @Test("Grace expiry turns the suspension into a departure") + func graceExpiryDeparts() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + _ = engine.handle(.connectionClosed(bob)) + + let expired = engine.handle(.timerFired(.suspension(bob))) + #expect(expired == [.emit(.peerLeft(bob))]) + #expect(engine.members.isEmpty) + + // The timer is one-shot: a stale second fire is a no-op. + #expect(engine.handle(.timerFired(.suspension(bob))) == []) + } + + @Test("Reconnect within grace resumes silently — membership never lapsed") + func reconnectWithinGraceResumes() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + _ = engine.handle(.connectionClosed(bob)) + + let reconnect = engine.handle(.connectionEstablished(bob)) + #expect(reconnect == [ + .cancelTimer(.suspension(bob)), + .emit(.peerResumed(bob)), + ]) + #expect(engine.members.contains(bob)) + + // A later (stale) grace timer must not evict the resumed member. + #expect(engine.handle(.timerFired(.suspension(bob))) == []) + #expect(engine.members.contains(bob)) + + // And a NORMAL close after the resume is a real departure again. + let closed = engine.handle(.connectionClosed(bob)) + #expect(closed == [.emit(.peerLeft(bob))]) + } + + @Test("Local suspend announces to connected members and arms local grace") + func localSuspendAnnounces() { + var engine = engineWithMember(alice, member: bob) + + let effects = engine.handle(.command(.suspend(grace: 60))) + #expect(effects == [ + .sendSignal(.suspend(graceMs: 60_000), to: bob), + .startTimer(.suspension(bob), duration: 60), + ]) + + // Our own connection losses while frozen must not evict members. + _ = engine.handle(.connectionClosed(bob)) + #expect(engine.members.contains(bob)) + } + + @Test("Resume re-dials suspended members whose connections died") + func resumeRedials() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.command(.suspend(grace: 60))) + _ = engine.handle(.connectionClosed(bob)) + + let effects = engine.handle(.command(.resume)) + #expect(effects == [.connect(to: bob)]) + + // The re-dial completing resumes the member. + let reconnected = engine.handle(.connectionEstablished(bob)) + #expect(reconnected == [ + .cancelTimer(.suspension(bob)), + .emit(.peerResumed(bob)), + ]) + } + + @Test("Resume with the connection still alive sheds the suspension in place") + func resumeWithLiveConnection() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.command(.suspend(grace: 60))) + + // No close happened (short background): no re-dial, just cleanup. + #expect(engine.handle(.command(.resume)) == [.cancelTimer(.suspension(bob))]) + #expect(engine.members.contains(bob)) + + // A genuine close after that is a real departure again. + #expect(engine.handle(.connectionClosed(bob)) == [.emit(.peerLeft(bob))]) + } + + @Test("Grace expiry never evicts a member whose connection is alive") + func expiryWithLiveConnectionIsNoop() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + + // The link never dropped (short background) and the suspender has no + // resume trigger to send — only this timer runs. It must not depart + // a live member. + #expect(engine.handle(.timerFired(.suspension(bob))) == []) + #expect(engine.members.contains(bob)) + + // Suspension shed: the next close is a normal departure. + #expect(engine.handle(.connectionClosed(bob)) == [.emit(.peerLeft(bob))]) + } + + @Test("A remote's requested grace is clamped to the configured maximum") + func graceClampedToMaximum() { + var engine = engineWithMember(alice, member: bob) + let effects = engine.handle(.signal(.suspend(graceMs: 3_600_000), from: bob)) + #expect(effects == [ + .startTimer(.suspension(bob), duration: 120), + .emit(.peerSuspended(bob)), + ]) + } + + @Test("Suspend from a non-member is ignored (membership gate, DD-6)") + func suspendFromNonMemberIgnored() { + var engine = ProtocolEngine(localPeer: alice) + _ = engine.handle(.connectionEstablished(bob)) + #expect(engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) == []) + } + + @Test("Leave clears suspensions — stale grace timers no-op") + func leaveClearsSuspensions() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + _ = engine.handle(.command(.leave)) + #expect(engine.handle(.timerFired(.suspension(bob))) == []) + #expect(engine.members.isEmpty) + } + + @Test("Suspend signal round-trips the wire codec") + func suspendSignalRoundTrip() throws { + let signal = Signal.suspend(graceMs: 45_000) + let decoded = try SignalCodec.decode(signal.encoded) + guard case .suspend(let view) = decoded.body else { + Issue.record("expected .suspend body, got \(decoded.body)") + return + } + #expect(view.graceMs == 45_000) + } +} diff --git a/Tests/StormoTests/SuspensionRuntimeTests.swift b/Tests/StormoTests/SuspensionRuntimeTests.swift new file mode 100644 index 0000000..bf49135 --- /dev/null +++ b/Tests/StormoTests/SuspensionRuntimeTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing + +import Stormo +import StormoTestKit + +/// Tier-2 runtime tests for the suspension grace protocol (C-5): the full +/// announce → freeze (transport-level kill, no goodbye) → hold → resume/expiry +/// loop over `InMemoryTransport`, using `KillSwitchTransport` to sever links +/// the way iOS suspension does — silently. +@Suite("Suspension grace over InMemoryTransport") +struct SuspensionRuntimeTests { + + /// Bounded wait for the first membership event matching `predicate` — + /// a missing event must fail the test, not hang the suite. + private func firstMembership( + of stream: AsyncStream, + within seconds: TimeInterval, + where predicate: @escaping @Sendable (MembershipEvent) -> Bool + ) async -> MembershipEvent? { + await withTaskGroup(of: MembershipEvent?.self) { group in + group.addTask { + for await event in stream where predicate(event) { return event } + return nil + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + let winner = await group.next() ?? nil + group.cancelAll() + return winner + } + } + + @Test("Backgrounded peer suspends, survives the silent link kill, and resumes") + func suspendKillResume() async throws { + let hub = InMemoryTransport.Hub() + // peer-0 is the backgrounder: it invited peer-1 (formMesh: i < j), so + // it retains the endpoint its resume re-dial needs. + let killable = KillSwitchTransport(base: InMemoryTransport(hub: hub)) + let backgrounder = PeerSession( + identity: PeerIdentity(name: "peer-0"), service: "_susp._udp", transport: killable) + let observer = PeerSession( + identity: PeerIdentity(name: "peer-1"), service: "_susp._udp", + transport: InMemoryTransport(hub: hub)) + _ = try await formMesh([backgrounder, observer]) + let backgrounderID = await backgrounder.identity.id + let observerMembership = await observer.membership + + // didEnterBackground: announce, and wait until the observer has the + // notice — the freeze must not race the signal onto a dead link. + await backgrounder.announceSuspension(gracePeriod: 8) + let suspendedEvent = await firstMembership(of: observerMembership, within: 5) { + if case .suspended(let id) = $0 { return id == backgrounderID } + return false + } + #expect(suspendedEvent != nil, "observer must see the suspension notice") + + // iOS freezes the app: every link dies silently, no protocol goodbye. + await killable.kill() + try await Task.sleep(nanoseconds: 300_000_000) + + // Grace holds membership on BOTH sides. + #expect(await observer.members.count == 1, "suspended member must survive the kill") + #expect(await backgrounder.members.count == 1, "frozen side keeps its members too") + + // didBecomeActive: re-dial. The observer sees the resume; membership + // never lapsed (no .left/.joined pair). + await backgrounder.resume() + let resumedEvent = await firstMembership(of: observerMembership, within: 5) { + if case .resumed(let member) = $0 { return member.id == backgrounderID } + return false + } + #expect(resumedEvent != nil, "observer must see the resume") + + // The revived link carries traffic. + let observerInbox = await observer.messages + try await backgrounder.send(Data([0xB0]), to: .all, delivery: .reliable) + let got = await withTaskGroup(of: Data?.self) { group in + group.addTask { + for await message in observerInbox { return message.payload } + return nil + } + group.addTask { + try? await Task.sleep(nanoseconds: 5_000_000_000) + return nil + } + let winner = await group.next() ?? nil + group.cancelAll() + return winner + } + #expect(got == Data([0xB0]), "post-resume send must reach the observer") + + await backgrounder.disconnect() + await observer.disconnect() + } + + @Test("Grace expiry without a resume becomes a normal departure") + func graceExpiryDeparts() async throws { + let hub = InMemoryTransport.Hub() + let killable = KillSwitchTransport(base: InMemoryTransport(hub: hub)) + let backgrounder = PeerSession( + identity: PeerIdentity(name: "peer-0"), service: "_suspx._udp", transport: killable) + let observer = PeerSession( + identity: PeerIdentity(name: "peer-1"), service: "_suspx._udp", + transport: InMemoryTransport(hub: hub)) + _ = try await formMesh([backgrounder, observer]) + let backgrounderID = await backgrounder.identity.id + let observerMembership = await observer.membership + + await backgrounder.announceSuspension(gracePeriod: 1) + let suspendedEvent = await firstMembership(of: observerMembership, within: 5) { + if case .suspended(let id) = $0 { return id == backgrounderID } + return false + } + #expect(suspendedEvent != nil, "observer must see the suspension notice") + await killable.kill() + + // No resume: after the ~1 s grace the suspension becomes .left. + let leftEvent = await firstMembership(of: observerMembership, within: 6) { + if case .left(let id) = $0 { return id == backgrounderID } + return false + } + #expect(leftEvent != nil, "grace expiry must surface as a departure") + #expect(await observer.members.isEmpty) + + await backgrounder.disconnect() + await observer.disconnect() + } +} From 30be2faaa23107247ad1fd906d206c7b4f09965b Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 21:49:37 -0700 Subject: [PATCH 02/11] Comment cleanup: keep constraints, drop narration Co-Authored-By: Claude Fable 5 --- Sources/Stormo/Events.swift | 9 +-- Sources/Stormo/PeerSession.swift | 14 ++--- Sources/StormoProtocol/ProtocolEngine.swift | 59 +++++++------------ Sources/StormoTestKit/InMemoryTransport.swift | 4 +- .../StormoTests/SuspensionRuntimeTests.swift | 6 +- 5 files changed, 33 insertions(+), 59 deletions(-) diff --git a/Sources/Stormo/Events.swift b/Sources/Stormo/Events.swift index 87ff303..4bd1e65 100644 --- a/Sources/Stormo/Events.swift +++ b/Sources/Stormo/Events.swift @@ -57,13 +57,10 @@ public enum MembershipEvent: Sendable { case joined(SessionPeer) case left(PeerID) case unreachable(PeerID) - /// The member announced suspension (iOS backgrounding, C-5). It is STILL - /// a member: its connection loss is expected, and either `.resumed` - /// follows (reconnect within the grace window) or `.left` does (grace - /// expired). + /// Member announced suspension (C-5): STILL a member — either `.resumed` + /// follows (reconnect within grace) or `.left` does (grace expired). case suspended(PeerID) - /// A suspended member reconnected within its grace window. Membership - /// never lapsed — no `.left`/`.joined` pair is emitted around it. + /// Suspended member reconnected within grace; no `.left`/`.joined` pair. case resumed(SessionPeer) /// A previously known peer reconnected with a different key (TrustPolicy.automatic /// continuity warning, FR-21). diff --git a/Sources/Stormo/PeerSession.swift b/Sources/Stormo/PeerSession.swift index ec65136..6fb7156 100644 --- a/Sources/Stormo/PeerSession.swift +++ b/Sources/Stormo/PeerSession.swift @@ -237,19 +237,15 @@ public actor PeerSession { // MARK: Suspension (C-5 — iOS backgrounding) - /// Announce to every connected member that this app is about to be - /// suspended (call from `didEnterBackground`). Members treat the - /// connection loss that follows as a suspension — membership survives for - /// `gracePeriod` (each side clamps to its configured maximum) — instead - /// of an immediate departure. Pair with ``resume()`` on foreground. + /// Announce the coming suspension (call from `didEnterBackground`): + /// members hold membership through the connection loss for `gracePeriod` + /// (each side clamps to its configured maximum). Pair with ``resume()``. public func announceSuspension(gracePeriod: TimeInterval = 60) async { run(.command(.suspend(grace: gracePeriod))) } - /// Re-establish connections to members suspended while this app was - /// frozen (call from `didBecomeActive`). Members whose links survived - /// simply shed their suspension; the rest are re-dialed via their - /// retained endpoints, falling back to the grace timer on failure. + /// Re-establish connections that died while frozen (call from + /// `didBecomeActive`); re-dials use the retained endpoints. public func resume() async { run(.command(.resume)) } diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 1499556..b405abd 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -36,12 +36,10 @@ public struct ProtocolEngine: Sendable { case respondToInvitation(from: PeerID, accept: Bool) case send(Data, to: Recipients, delivery: Delivery) case leave - /// The local app is about to be suspended (iOS backgrounding, C-5): - /// announce it to every connected member and treat their inevitable - /// connection losses as suspensions — not departures — for `grace`. + /// Local app about to background (C-5): announce, and treat members' + /// connection losses as suspensions for `grace`, not departures. case suspend(grace: TimeInterval) - /// The local app returned to the foreground: re-dial every suspended - /// member whose connection died while we were frozen. + /// Local app foregrounded: re-dial suspended members with dead links. case resume } @@ -63,8 +61,7 @@ public struct ProtocolEngine: Sendable { public enum TimerKey: Hashable, Sendable { case invitation(PeerID) - /// Suspension grace window (C-5): expiry turns a suspended member into - /// a departed one. + /// Grace window (C-5): expiry turns a suspended member into a departure. case suspension(PeerID) } @@ -73,12 +70,10 @@ public struct ProtocolEngine: Sendable { case invitationFailed(PeerID, reason: InvitationFailure) case peerJoined(PeerID) case peerLeft(PeerID) - /// A member announced suspension (backgrounded, C-5). It is still a - /// member; its connection loss is expected and does not emit `peerLeft` + /// Member announced suspension (C-5): still a member; no `peerLeft` /// until the grace window expires. case peerSuspended(PeerID) - /// A suspended member reconnected within its grace window. Membership - /// never lapsed. + /// Suspended member reconnected within grace; membership never lapsed. case peerResumed(PeerID) case messageReceived(Data, from: PeerID, delivery: Delivery) /// A member announced a resource transfer on the control stream (FR-17). @@ -100,8 +95,8 @@ public struct ProtocolEngine: Sendable { public struct Configuration: Sendable { public var invitationTimeout: TimeInterval - /// Ceiling on the suspension grace a peer may request (C-5): a remote - /// peer must not be able to park itself as a zombie member forever. + /// Ceiling on requested suspension grace — a remote must not park + /// itself as a zombie member forever. public var maxSuspensionGrace: TimeInterval public init(invitationTimeout: TimeInterval = 30, @@ -127,9 +122,8 @@ public struct ProtocolEngine: Sendable { // Zero-copy (DD-5/DD-6): retain the verified Signal (≤64 KB buffer) rather // than copying fields out of it. private var pendingIncoming: [PeerID: Signal] = [:] - // Members under a suspension grace window (C-5): their connection loss is - // expected and keeps membership; cleared by resume (connectionEstablished) - // or by the suspension timer turning them into departures. + // Members under a suspension grace window (C-5); cleared by reconnect or + // by the suspension timer turning them into departures. private var suspended: Set = [] public init(localPeer: PeerID, configuration: Configuration = Configuration()) { @@ -153,8 +147,7 @@ public struct ProtocolEngine: Sendable { case .connectionEstablished(let peer): connections.insert(peer) - // A suspended member reconnecting within its grace window resumes - // silently (C-5): membership never lapsed. + // A suspended member reconnecting within grace resumes silently. if suspended.remove(peer) != nil { return [ .cancelTimer(.suspension(peer)), @@ -180,8 +173,7 @@ public struct ProtocolEngine: Sendable { } pendingIncoming.removeValue(forKey: peer) if suspended.contains(peer) { - // Expected loss (C-5): the member is suspended, membership - // survives until the suspension timer expires or it resumes. + // Expected loss: membership survives until grace expiry. } else if members.remove(peer) != nil { // FR-14: one peer's departure never disturbs the rest. effects.append(.emit(.peerLeft(peer))) @@ -203,11 +195,9 @@ public struct ProtocolEngine: Sendable { ] case .timerFired(.suspension(let peer)): - // Grace expired without a resume: the suspension becomes a - // departure (C-5). A no-op if the peer resumed or we left — and - // never a departure while the connection is alive (a short - // background can end without the link ever dropping; the other - // side gets no resume trigger, only this timer). + // Grace expired → departure. Never while the connection is alive: + // a short background can end without the link dropping, and the + // observer side has no resume trigger, only this timer. guard suspended.remove(peer) != nil else { return [] } if connections.contains(peer) { return [] } guard members.remove(peer) != nil else { return [] } @@ -281,11 +271,9 @@ public struct ProtocolEngine: Sendable { .map { .closeConnection($0) } case .suspend(let grace): - // Announce to every connected member, and mark ALL members - // suspended locally: our own connection-closed inputs (observed - // now or queued until we thaw) must not evict them before - // `.resume` gets a chance to re-dial. Local timers freeze with - // the process, so effectively the grace runs from our wake-up. + // Mark ALL members suspended locally: our own connection-closed + // inputs (now or queued until thaw) must not evict them before + // `.resume` can re-dial. let duration = min(grace, configuration.maxSuspensionGrace) let signal = Signal.suspend(graceMs: UInt64(duration * 1000)) var effects: [Effect] = [] @@ -299,11 +287,9 @@ public struct ProtocolEngine: Sendable { return effects case .resume: - // Re-dial every suspended member whose connection died while we - // were frozen; a member whose link survived just sheds its - // suspension. Marks on dead-link members stay until - // `connectionEstablished` resumes them — a failed re-dial falls - // back to the grace timer. + // Re-dial suspended members with dead links (marks stay until + // `connectionEstablished`; a failed re-dial falls back to the + // grace timer); live links just shed their suspension. var effects: [Effect] = [] let marked = members .filter(suspended.contains) @@ -387,8 +373,7 @@ public struct ProtocolEngine: Sendable { return [.emit(.streamOpened(label: label, from: peer))] case .suspend(let suspend): - // Membership-gate (DD-6), and clamp the requested grace: a remote - // must not park itself as a zombie member indefinitely. + // Membership-gate (DD-6); clamp the requested grace. guard members.contains(peer) else { return [] } let duration = min( TimeInterval(suspend.graceMs) / 1000, configuration.maxSuspensionGrace) diff --git a/Sources/StormoTestKit/InMemoryTransport.swift b/Sources/StormoTestKit/InMemoryTransport.swift index cfa87bd..a783ee2 100644 --- a/Sources/StormoTestKit/InMemoryTransport.swift +++ b/Sources/StormoTestKit/InMemoryTransport.swift @@ -188,9 +188,7 @@ final class InMemoryConnection: PeerConnection, @unchecked Sendable { partner.incomingStreamsContinuation.finish() } // Driver contract (matches QUICConnection): BOTH ends observe an - // explicit `.closed` event, including the side that initiated the - // close — a transport-level kill must update the killer's own session - // bookkeeping too, or a later resume re-dial thinks the link is alive. + // explicit `.closed`, including the side that initiated it. ownContinuation.yield(.closed) ownContinuation.finish() incomingStreamsContinuation.finish() diff --git a/Tests/StormoTests/SuspensionRuntimeTests.swift b/Tests/StormoTests/SuspensionRuntimeTests.swift index bf49135..0d1a2cd 100644 --- a/Tests/StormoTests/SuspensionRuntimeTests.swift +++ b/Tests/StormoTests/SuspensionRuntimeTests.swift @@ -48,8 +48,8 @@ struct SuspensionRuntimeTests { let backgrounderID = await backgrounder.identity.id let observerMembership = await observer.membership - // didEnterBackground: announce, and wait until the observer has the - // notice — the freeze must not race the signal onto a dead link. + // Wait for the notice before killing — the freeze must not race the + // signal onto a dead link. await backgrounder.announceSuspension(gracePeriod: 8) let suspendedEvent = await firstMembership(of: observerMembership, within: 5) { if case .suspended(let id) = $0 { return id == backgrounderID } @@ -65,8 +65,6 @@ struct SuspensionRuntimeTests { #expect(await observer.members.count == 1, "suspended member must survive the kill") #expect(await backgrounder.members.count == 1, "frozen side keeps its members too") - // didBecomeActive: re-dial. The observer sees the resume; membership - // never lapsed (no .left/.joined pair). await backgrounder.resume() let resumedEvent = await firstMembership(of: observerMembership, within: 5) { if case .resumed(let member) = $0 { return member.id == backgrounderID } From e484f1aab0bd50288351d4e6b8dc8c1b171251e3 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 21:49:37 -0700 Subject: [PATCH 03/11] Surface suspend/resume to the MC-compat delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps need to show "peer backgrounded — waiting" instead of treating the peer as still fully present: new optional MultipeerSessionDelegate callbacks peerDidSuspend/peerDidResume (default no-op, non-breaking). The MC-shaped state story is unchanged — the peer stays .connected through its grace window and expiry arrives as .notConnected. E2E test pins the observer-side story the dialog needs: callback on the notice, still connected during grace, .notConnected on expiry. Co-Authored-By: Claude Fable 5 --- Sources/MPCCompat/CompatCore.swift | 19 ++++--- Sources/MPCCompat/MultipeerSession.swift | 24 ++++++--- Tests/MPCCompatTests/MPCCompatE2ETests.swift | 54 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index 88f1aa0..865403e 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -197,12 +197,19 @@ final class CompatCore: @unchecked Sendable { emitState(peer: member.id, state: .connected) case .left(let id), .unreachable(let id): emitState(peer: id, state: .notConnected) - case .suspended, .resumed: - // MC has no suspended state: a peer under its background grace - // window (C-5) simply STAYS `.connected` — grace expiry arrives - // as the normal `.left` → `.notConnected`, and a resume within - // grace is invisible (membership never lapsed). - break + case .suspended(let id): + // MC has no suspended state: the peer stays `.connected` through + // its grace window (expiry arrives as the normal `.left`); apps + // that want "peer backgrounded" UI get the beyond-MC callback. + delegateQueue.async { [weak self] in + guard let self, let session = self.boundSession else { return } + session.delegate?.session(session, peerDidSuspend: id) + } + case .resumed(let member): + delegateQueue.async { [weak self] in + guard let self, let session = self.boundSession else { return } + session.delegate?.session(session, peerDidResume: member.id) + } case .identityChanged: // TOFU continuity warning (FR-21) has no MCSession analog; ignore. break diff --git a/Sources/MPCCompat/MultipeerSession.swift b/Sources/MPCCompat/MultipeerSession.swift index dae13ef..fda5478 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -226,17 +226,15 @@ public final class MultipeerSession: @unchecked Sendable { // MARK: Background suspension (C-5 — no MCSession analog) - /// Announce that this app is about to be suspended (call from - /// `didEnterBackground`). Connected peers keep this device `.connected` - /// for the grace period instead of dropping it ~5 s after the freeze; - /// pair with ``resumeFromSuspension()`` on foreground. Beyond-MC API: MC - /// survived backgrounding at the OS level, a userspace transport cannot. + /// Call from `didEnterBackground`: peers keep this device `.connected` + /// for the grace period instead of dropping it ~5 s after the freeze. + /// Pair with ``resumeFromSuspension()``. public func announceSuspension(gracePeriod: TimeInterval = 60) { core?.announceSuspension(gracePeriod: gracePeriod) } - /// Re-establish connections suspended while the app was frozen (call - /// from `didBecomeActive`). + /// Call from `didBecomeActive`: re-establish connections that died while + /// the app was frozen. public func resumeFromSuspension() { core?.resumeSession() } @@ -265,4 +263,16 @@ public protocol MultipeerSessionDelegate: AnyObject { func session(_ session: MultipeerSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: PeerID) func session(_ session: MultipeerSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, with progress: Progress) func session(_ session: MultipeerSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, at localURL: URL?, withError error: Error?) + + /// Beyond-MC (C-5): the peer announced suspension (backgrounding). It + /// stays `.connected` through its grace window — either `peerDidResume` + /// follows, or `didChange .notConnected` on grace expiry. Default: no-op. + func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) + /// Beyond-MC (C-5): a suspended peer reconnected within grace. Default: no-op. + func session(_ session: MultipeerSession, peerDidResume peerID: PeerID) +} + +extension MultipeerSessionDelegate { + public func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) {} + public func session(_ session: MultipeerSession, peerDidResume peerID: PeerID) {} } diff --git a/Tests/MPCCompatTests/MPCCompatE2ETests.swift b/Tests/MPCCompatTests/MPCCompatE2ETests.swift index 8a88919..aec9e88 100644 --- a/Tests/MPCCompatTests/MPCCompatE2ETests.swift +++ b/Tests/MPCCompatTests/MPCCompatE2ETests.swift @@ -26,16 +26,19 @@ struct MPCCompatE2ETests { let data: AsyncStream<(Data, PeerID)> let resourceStarts: AsyncStream<(String, PeerID)> let resourceFinishes: AsyncStream<(String, URL?, Error?)> + let suspends: AsyncStream private let statesIn: AsyncStream<(PeerID, MultipeerSession.PeerState)>.Continuation private let dataIn: AsyncStream<(Data, PeerID)>.Continuation private let resourceStartsIn: AsyncStream<(String, PeerID)>.Continuation private let resourceFinishesIn: AsyncStream<(String, URL?, Error?)>.Continuation + private let suspendsIn: AsyncStream.Continuation init() { (states, statesIn) = AsyncStream.makeStream() (data, dataIn) = AsyncStream.makeStream() (resourceStarts, resourceStartsIn) = AsyncStream.makeStream() (resourceFinishes, resourceFinishesIn) = AsyncStream.makeStream() + (suspends, suspendsIn) = AsyncStream.makeStream() } func session(_ session: MultipeerSession, peer peerID: PeerID, didChange state: MultipeerSession.PeerState) { @@ -51,6 +54,9 @@ struct MPCCompatE2ETests { func session(_ session: MultipeerSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, at localURL: URL?, withError error: Error?) { resourceFinishesIn.yield((resourceName, localURL, error)) } + func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) { + suspendsIn.yield(peerID) + } } /// Auto-accepts every invitation with the supplied session, exactly like @@ -276,6 +282,54 @@ struct MPCCompatE2ETests { sessionA.disconnect() } + /// The observer-side story an app's "peer backgrounded" dialog needs: + /// `peerDidSuspend` fires on the notice, the peer stays `.connected` + /// through the grace window, and expiry arrives as `.notConnected`. + @Test("Suspension surfaces peerDidSuspend, then .notConnected on expiry") + func suspensionSurfacesToDelegate() async throws { + let hub = InMemoryTransport.Hub() + let peerA = PeerID(displayName: "SuspObserver") + let peerB = PeerID(displayName: "SuspSleeper") + let transportA = InMemoryTransport(hub: hub) + let transportB = InMemoryTransport(hub: hub) + + let sessionA = MultipeerSession(peer: peerA, service: Self.service, transport: transportA) + let recorderA = SessionRecorder() + sessionA.delegate = recorderA + let advDelegate = AutoAcceptAdvertiserRecorder(accepting: sessionA) + let advertiser = NearbyServiceAdvertiser( + peer: peerA, discoveryInfo: nil, serviceType: Self.service, transport: transportA) + advertiser.delegate = advDelegate + advertiser.startAdvertisingPeer() + + let sessionB = MultipeerSession(peer: peerB, service: Self.service, transport: transportB) + let recorderB = SessionRecorder() + sessionB.delegate = recorderB + let browserDelegate = BrowserRecorder() + let browser = NearbyServiceBrowser(peer: peerB, serviceType: Self.service, transport: transportB) + browser.delegate = browserDelegate + browser.startBrowsingForPeers() + + var found: PeerID? + for await (peer, _) in browserDelegate.found { found = peer; break } + browser.invitePeer(try #require(found), to: sessionB, withContext: nil, timeout: 10) + _ = try #require(await firstState(recorderA, matching: .connected)) + _ = try #require(await firstState(recorderB, matching: .connected)) + + // B backgrounds with a short grace; A gets the beyond-MC callback. + sessionB.announceSuspension(gracePeriod: 1) + var suspendedPeer: PeerID? + for await peer in recorderA.suspends { suspendedPeer = peer; break } + #expect(suspendedPeer?.displayName == "SuspSleeper") + #expect(sessionA.connectedPeers.count == 1, "suspended peer must stay connected") + + // The link dies while suspended; grace expiry surfaces as the normal + // MC-shaped departure. + sessionB.disconnect() + let departed = try #require(await firstState(recorderA, matching: .notConnected)) + #expect(departed.displayName == "SuspSleeper") + } + @Test("Advertiser, browser, and session with one PeerID share one CompatCore") func sharedCoreForOnePeer() { let peer = PeerID(displayName: "Shared") From cc3d2e2d1733a6a874c7b8d764c2b209d04a680d Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 22:44:39 -0700 Subject: [PATCH 04/11] Resume re-dials at a fixed 1/s rate until reconnect or grace expiry A single resume dial could fail (AWDL takes a beat to come back after thaw) and silently strand the resume until grace expiry. New .resumeRetry(peer) timer ticks a re-dial every resumeRetryInterval (1 s, no backoff by design); the loop dies on reconnect, grace expiry, or leave. Co-Authored-By: Claude Fable 5 --- Sources/StormoProtocol/ProtocolEngine.swift | 33 ++++++++++-- .../StormoProtocolTests/SuspensionTests.swift | 53 +++++++++++++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index b405abd..f518897 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -63,6 +63,8 @@ public struct ProtocolEngine: Sendable { case invitation(PeerID) /// Grace window (C-5): expiry turns a suspended member into a departure. case suspension(PeerID) + /// Fixed-rate resume re-dial tick; stops on reconnect or grace expiry. + case resumeRetry(PeerID) } public enum Event: Sendable, Equatable { @@ -98,11 +100,16 @@ public struct ProtocolEngine: Sendable { /// Ceiling on requested suspension grace — a remote must not park /// itself as a zombie member forever. public var maxSuspensionGrace: TimeInterval + /// Fixed interval between resume re-dials (no backoff by design — + /// the loop ends at reconnect or grace expiry). + public var resumeRetryInterval: TimeInterval public init(invitationTimeout: TimeInterval = 30, - maxSuspensionGrace: TimeInterval = 120) { + maxSuspensionGrace: TimeInterval = 120, + resumeRetryInterval: TimeInterval = 1) { self.invitationTimeout = invitationTimeout self.maxSuspensionGrace = maxSuspensionGrace + self.resumeRetryInterval = resumeRetryInterval } } @@ -151,6 +158,7 @@ public struct ProtocolEngine: Sendable { if suspended.remove(peer) != nil { return [ .cancelTimer(.suspension(peer)), + .cancelTimer(.resumeRetry(peer)), .emit(.peerResumed(peer)), ] } @@ -201,7 +209,20 @@ public struct ProtocolEngine: Sendable { guard suspended.remove(peer) != nil else { return [] } if connections.contains(peer) { return [] } guard members.remove(peer) != nil else { return [] } - return [.emit(.peerLeft(peer))] + return [ + .cancelTimer(.resumeRetry(peer)), + .emit(.peerLeft(peer)), + ] + + case .timerFired(.resumeRetry(let peer)): + // Fixed-rate re-dial while resuming; dies with the suspension. + guard suspended.contains(peer), members.contains(peer), + !connections.contains(peer) + else { return [] } + return [ + .connect(to: peer), + .startTimer(.resumeRetry(peer), duration: configuration.resumeRetryInterval), + ] } } @@ -287,9 +308,9 @@ public struct ProtocolEngine: Sendable { return effects case .resume: - // Re-dial suspended members with dead links (marks stay until - // `connectionEstablished`; a failed re-dial falls back to the - // grace timer); live links just shed their suspension. + // Re-dial suspended members with dead links at a fixed rate + // (resumeRetry ticks; the loop ends on reconnect or grace + // expiry); live links just shed their suspension. var effects: [Effect] = [] let marked = members .filter(suspended.contains) @@ -300,6 +321,8 @@ public struct ProtocolEngine: Sendable { effects.append(.cancelTimer(.suspension(member))) } else { effects.append(.connect(to: member)) + effects.append(.startTimer( + .resumeRetry(member), duration: configuration.resumeRetryInterval)) } } return effects diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift index b222ea3..d58e641 100644 --- a/Tests/StormoProtocolTests/SuspensionTests.swift +++ b/Tests/StormoProtocolTests/SuspensionTests.swift @@ -54,7 +54,7 @@ struct SuspensionTests { _ = engine.handle(.connectionClosed(bob)) let expired = engine.handle(.timerFired(.suspension(bob))) - #expect(expired == [.emit(.peerLeft(bob))]) + #expect(expired == [.cancelTimer(.resumeRetry(bob)), .emit(.peerLeft(bob))]) #expect(engine.members.isEmpty) // The timer is one-shot: a stale second fire is a no-op. @@ -70,6 +70,7 @@ struct SuspensionTests { let reconnect = engine.handle(.connectionEstablished(bob)) #expect(reconnect == [ .cancelTimer(.suspension(bob)), + .cancelTimer(.resumeRetry(bob)), .emit(.peerResumed(bob)), ]) #expect(engine.members.contains(bob)) @@ -105,16 +106,62 @@ struct SuspensionTests { _ = engine.handle(.connectionClosed(bob)) let effects = engine.handle(.command(.resume)) - #expect(effects == [.connect(to: bob)]) + #expect(effects == [ + .connect(to: bob), + .startTimer(.resumeRetry(bob), duration: 1), + ]) - // The re-dial completing resumes the member. + // The re-dial completing resumes the member and stops the loop. let reconnected = engine.handle(.connectionEstablished(bob)) #expect(reconnected == [ .cancelTimer(.suspension(bob)), + .cancelTimer(.resumeRetry(bob)), .emit(.peerResumed(bob)), ]) } + @Test("Resume re-dial ticks at a fixed rate until reconnect") + func resumeRetriesAtFixedRate() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.command(.suspend(grace: 60))) + _ = engine.handle(.connectionClosed(bob)) + _ = engine.handle(.command(.resume)) + + // The dial fails (radio not back yet): membership held, and each + // tick re-dials and re-arms — fixed rate, no backoff. + _ = engine.handle(.connectionClosed(bob)) + #expect(engine.members.contains(bob)) + let tick = engine.handle(.timerFired(.resumeRetry(bob))) + #expect(tick == [ + .connect(to: bob), + .startTimer(.resumeRetry(bob), duration: 1), + ]) + + // Reconnect stops the loop: a stale tick is a no-op. + _ = engine.handle(.connectionEstablished(bob)) + #expect(engine.handle(.timerFired(.resumeRetry(bob))) == []) + } + + @Test("Resume re-dial dies with the suspension") + func resumeRetryStopsOnExpiryAndLeave() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.command(.suspend(grace: 60))) + _ = engine.handle(.connectionClosed(bob)) + _ = engine.handle(.command(.resume)) + + // Grace expiry evicts the member; the next tick must go quiet. + _ = engine.handle(.timerFired(.suspension(bob))) + #expect(engine.handle(.timerFired(.resumeRetry(bob))) == []) + + // Same after leave. + var engine2 = engineWithMember(alice, member: bob) + _ = engine2.handle(.command(.suspend(grace: 60))) + _ = engine2.handle(.connectionClosed(bob)) + _ = engine2.handle(.command(.resume)) + _ = engine2.handle(.command(.leave)) + #expect(engine2.handle(.timerFired(.resumeRetry(bob))) == []) + } + @Test("Resume with the connection still alive sheds the suspension in place") func resumeWithLiveConnection() { var engine = engineWithMember(alice, member: bob) From 579952d582e2badd3f6f06f741a6b24703410885 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 22:49:16 -0700 Subject: [PATCH 05/11] DD-9: document the suspension grace design (C-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records today's design in the design doc: the Suspend signal, receiver grace semantics (clamp, no-peerLeft hold, expiry rules), sender-side local marking, fixed-rate rebuild-on-resume, the trust boundary (explicit notice only — silent drops still depart in ~5 s), and the MPCCompat mapping. S-5 narrows to what remains open: hardware validation of the thaw-time re-dial over AWDL. Co-Authored-By: Claude Fable 5 --- docs/design-mpc-successor.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/design-mpc-successor.md b/docs/design-mpc-successor.md index 6b4a93d..e811a35 100644 --- a/docs/design-mpc-successor.md +++ b/docs/design-mpc-successor.md @@ -285,6 +285,19 @@ The obvious alternative to a bespoke protocol is libp2p (the IPFS-lineage modula **Adopted from libp2p instead:** the PeerID identity encoding (multihash of the encoded public key, CIDv1 text representation) SHALL replace the ad-hoc SHA-256-of-raw-key format before the wire protocol freezes — near-zero cost now, and it keeps a future libp2p bridge (post-1.0 internet reach via relays) identity-compatible. Revisit trigger: if post-1.0 scope expands to internet-wide P2P (NAT traversal/relays), evaluate bridging to libp2p protocols rather than reinventing that tier. +### DD-9: Suspension grace — announced backgrounding is not a departure (C-5) — **adopted** + +iOS suspension freezes the process, PINGs stop, and the peer's 5 s idle timeout kills the QUIC connection — nothing in userspace can prevent that (MPC survived backgrounding only because system daemons owned its links). Stormo therefore makes suspension explicit rather than trying to keep sockets alive: + +- **Wire:** a `Suspend { grace_ms }` control signal, sent by the backgrounding app (`PeerSession.announceSuspension(gracePeriod:)` from `didEnterBackground`) over the still-alive connection — milliseconds of work, well inside iOS's background transition window. +- **Receiver:** membership-gated; the requested grace clamps to `Configuration.maxSuspensionGrace` (120 s default) so a remote cannot park itself as a zombie member. The member is marked suspended and a `suspension` timer arms. Its subsequent `connectionClosed` emits **no `peerLeft`** — membership survives. Timer expiry turns the suspension into an ordinary departure; expiry never evicts a member whose connection is alive (a short background can end without the link dropping, and the observer's only signal is this timer). +- **Sender:** marks all its members suspended locally (its own queued connection-closed inputs must not evict them while frozen) and arms local timers, which freeze with the process — effectively the grace runs from wake-up. +- **Resume:** `PeerSession.resume()` (from `didBecomeActive`) re-dials suspended members with dead links via their retained endpoints, at a **fixed rate** (`resumeRetryInterval`, 1 s, deliberately no backoff — the loop is bounded by reconnect or grace expiry). A reconnect within grace cancels the timers and emits `peerResumed`/`.resumed`: membership never lapsed, no re-invitation, no roster churn. Resume is always a **fresh QUIC connection** (rebuild-on-resume, not connection migration): the dead connection object is discarded normally, and the grace is membership bookkeeping above the transport, never a socket-lifetime trick. +- **Trust boundary:** suspension state enters only through an explicit, authenticated in-session `Suspend` or the local app's own command. A silent drop with no notice is still a departure in ~5 s — walk-away detection (QA-5) is unchanged. +- **MPCCompat:** MC has no suspended state, so a suspended peer simply stays `.connected` through its grace (expiry arrives as the normal `.notConnected`); apps that want "peer backgrounded" UI use the beyond-MC `peerDidSuspend`/`peerDidResume` delegate callbacks. + +Engine mechanics live in `ProtocolEngine` (`suspended` set, `TimerKey.suspension`/`.resumeRetry`), asserted by tier-1 spec tests (`SuspensionTests`) and the tier-2 announce→kill→resume/expiry loop (`SuspensionRuntimeTests`). + --- ## 7. Architecture Overview (module view) @@ -552,7 +565,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** | Hardware validation of DD-9 over AWDL: does the thaw-time re-dial reconnect within the grace on real radios (AWDL re-establishment latency after suspension)? | Device testing with app lifecycle scripting | DD-9 resume loop reconnects on-device across short/long backgrounds | | **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 | --- From 8eab429aba4d22680053688ffb55ac3578b93342 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 22:53:35 -0700 Subject: [PATCH 06/11] The frozen side arms no timers: grace runs from resume(), not announce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process about to be suspended cannot run timers, and wall-clock deadlines armed at announce would all fire at once on thaw — racing the re-dial and evicting members before resume() ran. The suspend command now only marks members and says goodbye; resume() (the next provable execution point) arms the grace timer alongside the fixed-rate re-dial. DD-9 updated to match. Co-Authored-By: Claude Fable 5 --- Sources/StormoProtocol/ProtocolEngine.swift | 24 ++++++++++++------- .../StormoProtocolTests/SuspensionTests.swift | 14 +++++------ docs/design-mpc-successor.md | 4 ++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index f518897..9ebdb3c 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -132,6 +132,9 @@ public struct ProtocolEngine: Sendable { // Members under a suspension grace window (C-5); cleared by reconnect or // by the suspension timer turning them into departures. private var suspended: Set = [] + // The clamped grace announced by OUR `.suspend`, consumed by `.resume` to + // arm grace timers at wake — the frozen side never runs timers. + private var localSuspensionGrace: TimeInterval? public init(localPeer: PeerID, configuration: Configuration = Configuration()) { self.localPeer = localPeer @@ -287,15 +290,19 @@ public struct ProtocolEngine: Sendable { pendingOutgoing.removeAll() pendingIncoming.removeAll() suspended.removeAll() // stale suspension timers no-op on fire + localSuspensionGrace = nil return open .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } .map { .closeConnection($0) } case .suspend(let grace): - // Mark ALL members suspended locally: our own connection-closed - // inputs (now or queued until thaw) must not evict them before - // `.resume` can re-dial. + // Mark ALL members suspended and say goodbye — nothing else. The + // process is about to freeze, so NO timers arm here: the marks + // keep queued connection-closed inputs from evicting members, and + // `.resume` (the next time our code provably runs) starts the + // grace clock. let duration = min(grace, configuration.maxSuspensionGrace) + localSuspensionGrace = duration let signal = Signal.suspend(graceMs: UInt64(duration * 1000)) var effects: [Effect] = [] for member in members.sorted(by: { $0.keyHash.lexicographicallyPrecedes($1.keyHash) }) { @@ -303,14 +310,15 @@ public struct ProtocolEngine: Sendable { if connections.contains(member) { effects.append(.sendSignal(signal, to: member)) } - effects.append(.startTimer(.suspension(member), duration: duration)) } return effects case .resume: - // Re-dial suspended members with dead links at a fixed rate - // (resumeRetry ticks; the loop ends on reconnect or grace - // expiry); live links just shed their suspension. + // Wake-up: re-dial suspended members with dead links at a fixed + // rate, bounded by a grace timer that starts NOW (grace runs from + // wake, not from the announce); live links just shed their marks. + let grace = localSuspensionGrace ?? configuration.maxSuspensionGrace + localSuspensionGrace = nil var effects: [Effect] = [] let marked = members .filter(suspended.contains) @@ -318,11 +326,11 @@ public struct ProtocolEngine: Sendable { for member in marked { if connections.contains(member) { suspended.remove(member) - effects.append(.cancelTimer(.suspension(member))) } else { effects.append(.connect(to: member)) effects.append(.startTimer( .resumeRetry(member), duration: configuration.resumeRetryInterval)) + effects.append(.startTimer(.suspension(member), duration: grace)) } } return effects diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift index d58e641..82f7a0b 100644 --- a/Tests/StormoProtocolTests/SuspensionTests.swift +++ b/Tests/StormoProtocolTests/SuspensionTests.swift @@ -84,15 +84,12 @@ struct SuspensionTests { #expect(closed == [.emit(.peerLeft(bob))]) } - @Test("Local suspend announces to connected members and arms local grace") + @Test("Local suspend announces and marks — no timers on a process about to freeze") func localSuspendAnnounces() { var engine = engineWithMember(alice, member: bob) let effects = engine.handle(.command(.suspend(grace: 60))) - #expect(effects == [ - .sendSignal(.suspend(graceMs: 60_000), to: bob), - .startTimer(.suspension(bob), duration: 60), - ]) + #expect(effects == [.sendSignal(.suspend(graceMs: 60_000), to: bob)]) // Our own connection losses while frozen must not evict members. _ = engine.handle(.connectionClosed(bob)) @@ -105,10 +102,13 @@ struct SuspensionTests { _ = engine.handle(.command(.suspend(grace: 60))) _ = engine.handle(.connectionClosed(bob)) + // Wake-up arms both clocks: the fixed-rate re-dial and the grace, + // which runs from NOW (the frozen process never ran timers). let effects = engine.handle(.command(.resume)) #expect(effects == [ .connect(to: bob), .startTimer(.resumeRetry(bob), duration: 1), + .startTimer(.suspension(bob), duration: 60), ]) // The re-dial completing resumes the member and stops the loop. @@ -167,8 +167,8 @@ struct SuspensionTests { var engine = engineWithMember(alice, member: bob) _ = engine.handle(.command(.suspend(grace: 60))) - // No close happened (short background): no re-dial, just cleanup. - #expect(engine.handle(.command(.resume)) == [.cancelTimer(.suspension(bob))]) + // No close happened (short background): the mark just sheds. + #expect(engine.handle(.command(.resume)) == []) #expect(engine.members.contains(bob)) // A genuine close after that is a real departure again. diff --git a/docs/design-mpc-successor.md b/docs/design-mpc-successor.md index e811a35..20ef246 100644 --- a/docs/design-mpc-successor.md +++ b/docs/design-mpc-successor.md @@ -291,8 +291,8 @@ iOS suspension freezes the process, PINGs stop, and the peer's 5 s idle timeout - **Wire:** a `Suspend { grace_ms }` control signal, sent by the backgrounding app (`PeerSession.announceSuspension(gracePeriod:)` from `didEnterBackground`) over the still-alive connection — milliseconds of work, well inside iOS's background transition window. - **Receiver:** membership-gated; the requested grace clamps to `Configuration.maxSuspensionGrace` (120 s default) so a remote cannot park itself as a zombie member. The member is marked suspended and a `suspension` timer arms. Its subsequent `connectionClosed` emits **no `peerLeft`** — membership survives. Timer expiry turns the suspension into an ordinary departure; expiry never evicts a member whose connection is alive (a short background can end without the link dropping, and the observer's only signal is this timer). -- **Sender:** marks all its members suspended locally (its own queued connection-closed inputs must not evict them while frozen) and arms local timers, which freeze with the process — effectively the grace runs from wake-up. -- **Resume:** `PeerSession.resume()` (from `didBecomeActive`) re-dials suspended members with dead links via their retained endpoints, at a **fixed rate** (`resumeRetryInterval`, 1 s, deliberately no backoff — the loop is bounded by reconnect or grace expiry). A reconnect within grace cancels the timers and emits `peerResumed`/`.resumed`: membership never lapsed, no re-invitation, no roster churn. Resume is always a **fresh QUIC connection** (rebuild-on-resume, not connection migration): the dead connection object is discarded normally, and the grace is membership bookkeeping above the transport, never a socket-lifetime trick. +- **Sender:** marks all its members suspended and says goodbye — nothing else. A process about to freeze arms **no timers** (frozen code doesn't run, and wall-clock deadlines would all fire at once on thaw); the marks only keep its queued connection-closed inputs from evicting members. +- **Resume:** `PeerSession.resume()` (from `didBecomeActive`) is the sender's next provable execution point, so its grace clock starts **here**: for each suspended member with a dead link it arms the grace timer and re-dials via the retained endpoint at a **fixed rate** (`resumeRetryInterval`, 1 s, deliberately no backoff — the loop is bounded by reconnect or that grace). A reconnect within grace cancels the timers and emits `peerResumed`/`.resumed`: membership never lapsed, no re-invitation, no roster churn. Resume is always a **fresh QUIC connection** (rebuild-on-resume, not connection migration): the dead connection object is discarded normally, and the grace is membership bookkeeping above the transport, never a socket-lifetime trick. - **Trust boundary:** suspension state enters only through an explicit, authenticated in-session `Suspend` or the local app's own command. A silent drop with no notice is still a departure in ~5 s — walk-away detection (QA-5) is unchanged. - **MPCCompat:** MC has no suspended state, so a suspended peer simply stays `.connected` through its grace (expiry arrives as the normal `.notConnected`); apps that want "peer backgrounded" UI use the beyond-MC `peerDidSuspend`/`peerDidResume` delegate callbacks. From f7b294842f5ce4d840ef164051ac22d2a89c8c41 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 28 Jul 2026 23:49:01 -0700 Subject: [PATCH 07/11] Resume must be announced: a link that never dropped has no reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device finding: the peer announced suspension, iOS did NOT kill the QUIC link (frames kept flowing at 30fps), and the observer waited forever — peerResumed only ever fired on connectionEstablished, and the returning side cleared its own mark silently, telling nobody. - New `Resume` signal (append-only union add). `.resume` on a member whose connection survived now announces instead of clearing silently; the receiver drops the hold and emits peerResumed. - Grace expiry on a LIVE connection emits peerResumed instead of returning nothing, so a lost Resume still frees the app from waiting. 93 tests green. Co-Authored-By: Claude Fable 5 --- Schemas/signal.fbs | 7 ++++ .../Generated/signal_generated.swift | 26 +++++++++++++- Sources/StormoProtocol/ProtocolEngine.swift | 17 ++++++++- Sources/StormoProtocol/Signal.swift | 11 ++++++ .../StormoProtocolTests/SuspensionTests.swift | 36 ++++++++++++++----- 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/Schemas/signal.fbs b/Schemas/signal.fbs index 659cf3a..7296ea0 100644 --- a/Schemas/signal.fbs +++ b/Schemas/signal.fbs @@ -75,6 +75,12 @@ table Suspend { grace_ms:ulong (id: 0); } +/// The sender is back in the foreground on a connection that SURVIVED the +/// suspension. A reconnect announces itself by connecting; a link that never +/// dropped has no such moment, so it says so explicitly. +table Resume { +} + union SignalBody { Invite, InviteResponse, @@ -83,6 +89,7 @@ union SignalBody { TransferOffer, StreamOpen, Suspend, + Resume, } /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on diff --git a/Sources/StormoProtocol/Generated/signal_generated.swift b/Sources/StormoProtocol/Generated/signal_generated.swift index 1cf223e..769ea2a 100644 --- a/Sources/StormoProtocol/Generated/signal_generated.swift +++ b/Sources/StormoProtocol/Generated/signal_generated.swift @@ -21,8 +21,9 @@ public enum Stormo_Wire_SignalBody: UInt8, UnionEnum { case transferoffer = 5 case streamopen = 6 case suspend = 7 + case resume = 8 - public static var max: Stormo_Wire_SignalBody { return .suspend } + public static var max: Stormo_Wire_SignalBody { return .resume } public static var min: Stormo_Wire_SignalBody { return .none_ } } @@ -380,6 +381,27 @@ public struct Stormo_Wire_Suspend: FlatBufferObject, Verifiable { } } +/// The sender is back in the foreground on a connection that SURVIVED the +/// suspension. A reconnect announces itself by connecting; a link that never +/// dropped has no such moment, so it says so explicitly. +public struct Stormo_Wire_Resume: FlatBufferObject, Verifiable { + + static func validateVersion() { FlatBuffersVersion_25_2_10() } + public var __buffer: ByteBuffer! { return _accessor.bb } + private var _accessor: Table + + private init(_ t: Table) { _accessor = t } + public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } + + public static func startResume(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 0) } + public static func endResume(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + + public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { + var _v = try verifier.visitTable(at: position) + _v.finish() + } +} + /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on /// the wire. Unknown body variants are ignored-and-logged, never fatal (QA-11). public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { @@ -435,6 +457,8 @@ public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_StreamOpen.self) case .suspend: try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_Suspend.self) + case .resume: + try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_Resume.self) } }) _v.finish() diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 9ebdb3c..2c026c4 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -210,7 +210,9 @@ public struct ProtocolEngine: Sendable { // a short background can end without the link dropping, and the // observer side has no resume trigger, only this timer. guard suspended.remove(peer) != nil else { return [] } - if connections.contains(peer) { return [] } + // Link alive: the peer never actually went away (or its Resume was + // lost). Report it back, never leave the app waiting. + if connections.contains(peer) { return [.emit(.peerResumed(peer))] } guard members.remove(peer) != nil else { return [] } return [ .cancelTimer(.resumeRetry(peer)), @@ -325,7 +327,11 @@ public struct ProtocolEngine: Sendable { .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } for member in marked { if connections.contains(member) { + // The link survived the freeze, so there is no reconnect + // for the peer to observe — say so explicitly or it waits + // forever. suspended.remove(member) + effects.append(.sendSignal(.resume(), to: member)) } else { effects.append(.connect(to: member)) effects.append(.startTimer( @@ -414,6 +420,15 @@ public struct ProtocolEngine: Sendable { .emit(.peerSuspended(peer)), ] + case .resume: + // Only meaningful for a member we are holding under grace. + guard members.contains(peer), suspended.remove(peer) != nil else { return [] } + return [ + .cancelTimer(.suspension(peer)), + .cancelTimer(.resumeRetry(peer)), + .emit(.peerResumed(peer)), + ] + case .unrecognized: // Forward compatibility (QA-11, DD-5 rule 1): ignore-and-log. return [] diff --git a/Sources/StormoProtocol/Signal.swift b/Sources/StormoProtocol/Signal.swift index e6f4758..43849ac 100644 --- a/Sources/StormoProtocol/Signal.swift +++ b/Sources/StormoProtocol/Signal.swift @@ -12,6 +12,7 @@ public typealias WireRosterUpdate = Stormo_Wire_RosterUpdate public typealias WireTransferOffer = Stormo_Wire_TransferOffer public typealias WireStreamOpen = Stormo_Wire_StreamOpen public typealias WireSuspend = Stormo_Wire_Suspend +public typealias WireResume = Stormo_Wire_Resume public typealias WireTransferId = Stormo_Wire_TransferId /// A control-plane message: a verified FlatBuffers buffer read **in place** @@ -64,6 +65,7 @@ public struct Signal: @unchecked Sendable, Equatable { case transferOffer(WireTransferOffer) case streamOpen(WireStreamOpen) case suspend(WireSuspend) + case resume(WireResume) /// Absent or unrecognized union variant (forward compatibility, QA-11): /// ignored-and-logged, never fatal (DD-5 rule 1). case unrecognized @@ -85,6 +87,8 @@ public struct Signal: @unchecked Sendable, Equatable { return root.body(type: WireStreamOpen.self).map(Body.streamOpen) ?? .unrecognized case .suspend: return root.body(type: WireSuspend.self).map(Body.suspend) ?? .unrecognized + case .resume: + return root.body(type: WireResume.self).map(Body.resume) ?? .unrecognized case .none_: return .unrecognized } @@ -146,6 +150,13 @@ public struct Signal: @unchecked Sendable, Equatable { } } + public static func resume() -> Signal { + build(.resume) { fbb in + let start = WireResume.startResume(&fbb) + return WireResume.endResume(&fbb, start: start) + } + } + private static func build( _ bodyType: WireSignalBody, _ makeBody: (inout FlatBufferBuilder) -> Offset diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift index 82f7a0b..d7e1420 100644 --- a/Tests/StormoProtocolTests/SuspensionTests.swift +++ b/Tests/StormoProtocolTests/SuspensionTests.swift @@ -162,28 +162,29 @@ struct SuspensionTests { #expect(engine2.handle(.timerFired(.resumeRetry(bob))) == []) } - @Test("Resume with the connection still alive sheds the suspension in place") + @Test("Resume on a surviving link ANNOUNCES — the peer has no reconnect to see") func resumeWithLiveConnection() { var engine = engineWithMember(alice, member: bob) _ = engine.handle(.command(.suspend(grace: 60))) - // No close happened (short background): the mark just sheds. - #expect(engine.handle(.command(.resume)) == []) + // No close happened (short background). The peer is holding us under + // grace and will never observe a reconnect, so say we are back. + #expect(engine.handle(.command(.resume)) == [.sendSignal(.resume(), to: bob)]) #expect(engine.members.contains(bob)) // A genuine close after that is a real departure again. #expect(engine.handle(.connectionClosed(bob)) == [.emit(.peerLeft(bob))]) } - @Test("Grace expiry never evicts a member whose connection is alive") - func expiryWithLiveConnectionIsNoop() { + @Test("Grace expiry on a live link resumes the member, never departs it") + func expiryWithLiveConnectionResumes() { var engine = engineWithMember(alice, member: bob) _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - // The link never dropped (short background) and the suspender has no - // resume trigger to send — only this timer runs. It must not depart - // a live member. - #expect(engine.handle(.timerFired(.suspension(bob))) == []) + // The link never dropped and the Resume never arrived (lost, or the + // peer never called resume). Expiry must free the app from waiting, + // not depart a member we are demonstrably connected to. + #expect(engine.handle(.timerFired(.suspension(bob))) == [.emit(.peerResumed(bob))]) #expect(engine.members.contains(bob)) // Suspension shed: the next close is a normal departure. @@ -216,6 +217,23 @@ struct SuspensionTests { #expect(engine.members.isEmpty) } + @Test("Resume signal clears a grace hold on a link that never dropped") + func inboundResumeClearsHold() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) + + let effects = engine.handle(.signal(.resume(), from: bob)) + #expect(effects == [ + .cancelTimer(.suspension(bob)), + .cancelTimer(.resumeRetry(bob)), + .emit(.peerResumed(bob)), + ]) + #expect(engine.members.contains(bob)) + + // Not holding anything: a stray Resume is inert. + #expect(engine.handle(.signal(.resume(), from: bob)) == []) + } + @Test("Suspend signal round-trips the wire codec") func suspendSignalRoundTrip() throws { let signal = Signal.suspend(graceMs: 45_000) From 84e9d8949200c790c650546328494169af34149e Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 29 Jul 2026 00:28:40 -0700 Subject: [PATCH 08/11] Treat a grace-held member's fresh invite as a rejoin, not a duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device bug: the remote app was killed and relaunched while the camera still held it under grace. Identity is persisted, so it re-invited as the SAME PeerID — and the invite handler dropped it via `guard !members.contains(peer)`. No response was ever sent, the dialer idled out and retried forever; waiting out the grace was the only cure. A member returning from a grace hold now gets rejoin treatment: peerLeft for the session that really did end, then the invite proceeds normally. The discriminator is deliberately narrow. Gating on "member reconnected" broke mesh formation (7- and 21-peer sweeps): roster gossip dials peers who are ALREADY members, so that state is normal there. Gating on "member returning from a SUSPENSION hold" leaves mesh untouched — formation peers are never suspended. 94 tests green, mesh sweeps included. Co-Authored-By: Claude Fable 5 --- Sources/StormoProtocol/ProtocolEngine.swift | 33 +++++++++++++++++-- .../StormoProtocolTests/SuspensionTests.swift | 25 ++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 2c026c4..988c712 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -135,6 +135,10 @@ public struct ProtocolEngine: Sendable { // The clamped grace announced by OUR `.suspend`, consumed by `.resume` to // arm grace timers at wake — the frozen side never runs timers. private var localSuspensionGrace: TimeInterval? + // Members that came back from a suspension grace hold. A fresh invite + // from one is a relaunched app (new session) rather than a duplicate; + // mesh-formation peers are never suspended, so they are never flagged. + private var reconnectedMembers: Set = [] public init(localPeer: PeerID, configuration: Configuration = Configuration()) { self.localPeer = localPeer @@ -159,6 +163,10 @@ public struct ProtocolEngine: Sendable { connections.insert(peer) // A suspended member reconnecting within grace resumes silently. if suspended.remove(peer) != nil { + // Held under grace and now back: this is either the SAME + // process resuming, or a relaunched one about to invite. The + // invite handler decides; mesh peers never reach here. + reconnectedMembers.insert(peer) return [ .cancelTimer(.suspension(peer)), .cancelTimer(.resumeRetry(peer)), @@ -177,6 +185,7 @@ public struct ProtocolEngine: Sendable { case .connectionClosed(let peer): connections.remove(peer) + reconnectedMembers.remove(peer) var effects: [Effect] = [] if pendingOutgoing.removeValue(forKey: peer) != nil { effects.append(.cancelTimer(.invitation(peer))) @@ -292,6 +301,7 @@ public struct ProtocolEngine: Sendable { pendingOutgoing.removeAll() pendingIncoming.removeAll() suspended.removeAll() // stale suspension timers no-op on fire + reconnectedMembers.removeAll() localSuspensionGrace = nil return open .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } @@ -350,11 +360,28 @@ public struct ProtocolEngine: Sendable { // fields persisted into engine state (`peerID`). switch signal.body { case .invite(let invite): - guard !members.contains(peer) else { return [] } - guard let inviter = invite.inviter?.peerID else { return [] } + // 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. + var effects: [Effect] = [] + if members.contains(peer) { + // Glare during mesh formation (both sides invite at once) is a + // duplicate — drop it. Only a member returning from a grace + // hold is introducing a genuinely new session. + guard reconnectedMembers.remove(peer) != nil else { return [] } + members.remove(peer) + suspended.remove(peer) + effects.append(.cancelTimer(.suspension(peer))) + effects.append(.cancelTimer(.resumeRetry(peer))) + effects.append(.emit(.peerLeft(peer))) + } + guard let inviter = invite.inviter?.peerID else { return effects } pendingIncoming[peer] = signal // retain the buffer, not a copy let context = invite.hasContext ? Data(invite.context) : nil // app-ownership copy - return [.emit(.invitationReceived(from: inviter, context: context))] + effects.append(.emit(.invitationReceived(from: inviter, context: context))) + return effects case .inviteResponse(let response): guard pendingOutgoing.removeValue(forKey: peer) != nil else { return [] } diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift index d7e1420..f067b60 100644 --- a/Tests/StormoProtocolTests/SuspensionTests.swift +++ b/Tests/StormoProtocolTests/SuspensionTests.swift @@ -234,6 +234,31 @@ struct SuspensionTests { #expect(engine.handle(.signal(.resume(), from: bob)) == []) } + /// Device bug: the remote app was killed and relaunched while the camera + /// still held it (grace, or an unnoticed death). Identity is persisted, so + /// it re-invited as the SAME PeerID — and the engine dropped the invite as + /// a duplicate member, so the camera never answered and the relaunched app + /// could never reconnect. + @Test("A relaunched member's fresh invite is a rejoin, not a duplicate") + func reinviteFromMemberIsRejoin() { + var engine = engineWithMember(alice, member: bob) + _ = engine.handle(.signal(.suspend(graceMs: 60_000), from: bob)) + _ = engine.handle(.connectionClosed(bob)) + #expect(engine.members.contains(bob), "held under grace") + + // The relaunched process dials in and invites afresh. + _ = engine.handle(.connectionEstablished(bob)) + let effects = engine.handle(.signal(.invite(inviter: bob, context: nil), from: bob)) + #expect(effects.contains(.emit(.peerLeft(bob))), "the old session really did end") + #expect(effects.contains(.emit(.invitationReceived(from: bob, context: nil))), + "…and the newcomer must be offered to the app") + + // Accepting completes the rejoin. + let accepted = engine.handle(.command(.respondToInvitation(from: bob, accept: true))) + #expect(accepted.contains(.emit(.peerJoined(bob)))) + #expect(engine.members.contains(bob)) + } + @Test("Suspend signal round-trips the wire codec") func suspendSignalRoundTrip() throws { let signal = Signal.suspend(graceMs: 45_000) From c93659092b7aa9e7bdce36de3877f22cc484bb9b Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 29 Jul 2026 00:47:04 -0700 Subject: [PATCH 09/11] Document the MPC habits that break on this transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything in this commit was learned by losing a day to it on device. README migration guide gains three sections: - Two MPC habits to drop: never rebuild the session per attempt (it closes the connection carrying the invitation — first connect works, every later one dies right after a successful handshake), and never restart an attempt still in flight (a QUIC dial takes seconds; a 1 s retry loop cancels its own handshake forever). - Backgrounding is the app's job now: announceSuspension/resume plus the peerDidSuspend/peerDidResume callbacks, since MPC's daemon-owned link survived suspension and a userspace QUIC connection cannot. - foundPeer re-fires with an upgraded display name; update peer lists in place or the AWDL key-hash placeholder is frozen in the UI. CompatCore's own doc comment recommended the rebuild pattern by name — corrected, and MultipeerSession.disconnect() now carries the warning where a caller reaching for a fresh session will actually read it. Co-Authored-By: Claude Fable 5 --- README.md | 39 ++++++++++++++++++++++++ Sources/MPCCompat/CompatCore.swift | 13 +++++--- Sources/MPCCompat/MultipeerSession.swift | 8 +++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c7ba19f..f322af4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,45 @@ invitations — unchanged call sites. `PeerSession.openStream`. - No 8-peer cap; sessions support 32+ peers full-mesh. +**6. Two MPC habits you must drop** — both are patterns that were correct +against MPC and actively break here. If your app connects once and then never +again, look here first: + +- **Do NOT rebuild the session per connection attempt.** MPC apps commonly + create a virgin `MCSession` before each invite/accept, because Apple never + documented a torn-down `MCSession` as reusable and a reused one was the + classic cause of invites wedged in `.connecting`. Here `MultipeerSession` is + a *facade over one long-lived peer session*: rebuilding resets no transport, + and `disconnect()` closes every open connection — including the one that + just completed its handshake and delivered the invitation you are about to + accept. The symptom is distinctive: the first connection works, every later + one dies milliseconds after a successful handshake. Keep one session and + invite/accept on it. +- **Do NOT restart a connection attempt that is still in flight.** A QUIC dial + (handshake + TLS + `PeerHello`) takes seconds, where MPC's invite felt + instantaneous. A fixed-rate reconnect loop that re-invites every second will + keep cancelling its own handshake and never connect. Retry on an attempt's + *failure*, not on a timer that ignores it. + +**7. Backgrounding is yours to handle now.** MPC's link was owned by system +daemons and survived app suspension; a userspace QUIC connection cannot — the +peer sees the connection die within ~5 s of the freeze. Call +`announceSuspension(gracePeriod:)` from `didEnterBackground` and +`resumeFromSuspension()` from `didBecomeActive`: peers then hold membership +across the freeze instead of reporting a departure, and reconnect silently. +The beyond-MC `peerDidSuspend`/`peerDidResume` delegate callbacks let you show +a "reconnecting" state; a peer under its grace window stays `.connected`, and +grace expiry arrives as the ordinary `.notConnected`. See +[DD-9](docs/design-mpc-successor.md) for the protocol. + +**8. `foundPeer` can fire more than once for the same peer.** Over +peer-to-peer Wi-Fi a peer is first surfaced from a name-only Bonjour find whose +`displayName` is a key-hash placeholder; when TXT resolution catches up the +peer is re-delivered with its real name. `PeerID` equality is key-hash-only, so +both deliveries compare equal — a peer list must **update the stored value in +place**, never dedup-and-drop it, or the placeholder name is frozen in your UI +forever. (`lostPeer` gives you back the same enriched `PeerID` you were shown.) + ## Docs [Design document](docs/design-mpc-successor.md) · diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index 865403e..ee9c2df 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -22,11 +22,14 @@ 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 +/// The session is reusable after `disconnect()`, and callers are expected to +/// reuse it. Rebuilding a `MultipeerSession` per connection attempt (a common +/// MPC habit) resets no transport here — it only routes to ``leaveSession()``, +/// which closes every open connection, including one that just completed its +/// handshake and delivered an invitation. ``teardown()`` is the deliberate +/// full stop; the next action lazily rebuilds a `PeerSession` under the *same* +/// identity via ``liveSession()``. 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 fda5478..c0f9008 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -211,6 +211,14 @@ public final class MultipeerSession: @unchecked Sendable { throw StormoError.unimplemented("MultipeerSession.startStream") } + /// Drops this session's peer connections and membership, exactly like + /// `MCSession.disconnect()`. The session stays usable — reuse it. + /// + /// - Important: do NOT build a fresh session per connection attempt. This + /// object is a facade over one long-lived peer session, so a "virgin + /// session" resets no transport; the disconnect that comes with it + /// closes every open connection, including one whose handshake just + /// completed and carried the invitation being accepted. public func disconnect() { // MCSession semantics: drop session connections/membership ONLY. // Advertiser/browser (independent objects in MPC) keep running, and From e3d926fff37b407eadae2c1c09bcdbe4eb2067c7 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 29 Jul 2026 00:48:18 -0700 Subject: [PATCH 10/11] Tighten the migration notes --- README.md | 54 +++++++++--------------- Sources/MPCCompat/CompatCore.swift | 12 +++--- Sources/MPCCompat/MultipeerSession.swift | 12 +++--- 3 files changed, 29 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f322af4..7e81919 100644 --- a/README.md +++ b/README.md @@ -107,44 +107,28 @@ invitations — unchanged call sites. `PeerSession.openStream`. - No 8-peer cap; sessions support 32+ peers full-mesh. -**6. Two MPC habits you must drop** — both are patterns that were correct -against MPC and actively break here. If your app connects once and then never +**6. Two MPC habits that break here.** If your app connects once and never again, look here first: -- **Do NOT rebuild the session per connection attempt.** MPC apps commonly - create a virgin `MCSession` before each invite/accept, because Apple never - documented a torn-down `MCSession` as reusable and a reused one was the - classic cause of invites wedged in `.connecting`. Here `MultipeerSession` is - a *facade over one long-lived peer session*: rebuilding resets no transport, - and `disconnect()` closes every open connection — including the one that - just completed its handshake and delivered the invitation you are about to - accept. The symptom is distinctive: the first connection works, every later - one dies milliseconds after a successful handshake. Keep one session and - invite/accept on it. -- **Do NOT restart a connection attempt that is still in flight.** A QUIC dial - (handshake + TLS + `PeerHello`) takes seconds, where MPC's invite felt - instantaneous. A fixed-rate reconnect loop that re-invites every second will - keep cancelling its own handshake and never connect. Retry on an attempt's - *failure*, not on a timer that ignores it. - -**7. Backgrounding is yours to handle now.** MPC's link was owned by system -daemons and survived app suspension; a userspace QUIC connection cannot — the -peer sees the connection die within ~5 s of the freeze. Call +- **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. Backgrounding is yours now.** MPC's link was daemon-owned and survived +suspension; a userspace QUIC connection dies ~5 s into the freeze. Call `announceSuspension(gracePeriod:)` from `didEnterBackground` and -`resumeFromSuspension()` from `didBecomeActive`: peers then hold membership -across the freeze instead of reporting a departure, and reconnect silently. -The beyond-MC `peerDidSuspend`/`peerDidResume` delegate callbacks let you show -a "reconnecting" state; a peer under its grace window stays `.connected`, and -grace expiry arrives as the ordinary `.notConnected`. See -[DD-9](docs/design-mpc-successor.md) for the protocol. - -**8. `foundPeer` can fire more than once for the same peer.** Over -peer-to-peer Wi-Fi a peer is first surfaced from a name-only Bonjour find whose -`displayName` is a key-hash placeholder; when TXT resolution catches up the -peer is re-delivered with its real name. `PeerID` equality is key-hash-only, so -both deliveries compare equal — a peer list must **update the stored value in -place**, never dedup-and-drop it, or the placeholder name is frozen in your UI -forever. (`lostPeer` gives you back the same enriched `PeerID` you were shown.) +`resumeFromSuspension()` from `didBecomeActive` — peers then hold membership +across it and reconnect silently. `peerDidSuspend`/`peerDidResume` drive your +"reconnecting" UI; grace expiry arrives as the ordinary `.notConnected` +([DD-9](docs/design-mpc-successor.md)). + +**8. `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 diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index ee9c2df..6a19b5d 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -23,13 +23,11 @@ import Stormo /// under ``lock``. Hence `@unchecked Sendable`. /// /// ### One session, reused — never rebuilt per attempt -/// The session is reusable after `disconnect()`, and callers are expected to -/// reuse it. Rebuilding a `MultipeerSession` per connection attempt (a common -/// MPC habit) resets no transport here — it only routes to ``leaveSession()``, -/// which closes every open connection, including one that just completed its -/// handshake and delivered an invitation. ``teardown()`` is the deliberate -/// full stop; the next action lazily rebuilds a `PeerSession` under the *same* -/// identity via ``liveSession()``. +/// 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 c0f9008..eca7783 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -211,14 +211,12 @@ public final class MultipeerSession: @unchecked Sendable { throw StormoError.unimplemented("MultipeerSession.startStream") } - /// Drops this session's peer connections and membership, exactly like - /// `MCSession.disconnect()`. The session stays usable — reuse it. + /// `MCSession.disconnect()` semantics. The session stays usable — reuse it. /// - /// - Important: do NOT build a fresh session per connection attempt. This - /// object is a facade over one long-lived peer session, so a "virgin - /// session" resets no transport; the disconnect that comes with it - /// closes every open connection, including one whose handshake just - /// completed and carried the invitation being accepted. + /// - 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 From 3a77f754519a635c7f55dcfe265e45debc2da0cb Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 29 Jul 2026 01:08:42 -0700 Subject: [PATCH 11/11] Remove the suspension grace: the connection ending is the only truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grace let a peer keep membership after its link died, on the promise that it was merely backgrounding. That promise cost four bugs: an overlay that could never clear (a link that never dropped produced no resume event), a relaunched app whose invite was dropped as a duplicate member, two mesh regressions from special-casing that, and a UI held hostage for the length of the window whenever a peer simply died. Its only real benefit was skipping one invite round-trip on return. Gone: the Suspend/Resume signals, the suspend/resume commands, the peerSuspended/peerResumed events, the suspension and resumeRetry timers, the suspended/localSuspensionGrace/reconnectedMembers state, and the rejoin special case (unnecessary now — membership drops with the connection, so a returning peer's invite is simply accepted). Apps reconnect by re-inviting, which is what the retry loop already did. DD-9 and the README backgrounding section removed; S-5 restored to the open question of reconnect timings. 76 tests green. Co-Authored-By: Claude Fable 5 --- README.md | 10 +- Schemas/signal.fbs | 16 -- Sources/MPCCompat/CompatCore.swift | 23 -- Sources/MPCCompat/MultipeerSession.swift | 27 -- Sources/Stormo/Events.swift | 7 +- Sources/Stormo/PeerSession.swift | 21 -- .../Generated/signal_generated.swift | 68 +---- Sources/StormoProtocol/ProtocolEngine.swift | 163 +---------- Sources/StormoProtocol/Signal.swift | 21 -- Tests/MPCCompatTests/MPCCompatE2ETests.swift | 54 ---- .../StormoProtocolTests/SuspensionTests.swift | 272 ------------------ .../StormoTests/SuspensionRuntimeTests.swift | 129 --------- docs/design-mpc-successor.md | 15 +- 13 files changed, 9 insertions(+), 817 deletions(-) delete mode 100644 Tests/StormoProtocolTests/SuspensionTests.swift delete mode 100644 Tests/StormoTests/SuspensionRuntimeTests.swift diff --git a/README.md b/README.md index 7e81919..e9322a4 100644 --- a/README.md +++ b/README.md @@ -117,15 +117,7 @@ again, look here first: - **Never restart an attempt still in flight.** A QUIC dial takes seconds, not milliseconds. Retry on failure, not on a timer. -**7. Backgrounding is yours now.** MPC's link was daemon-owned and survived -suspension; a userspace QUIC connection dies ~5 s into the freeze. Call -`announceSuspension(gracePeriod:)` from `didEnterBackground` and -`resumeFromSuspension()` from `didBecomeActive` — peers then hold membership -across it and reconnect silently. `peerDidSuspend`/`peerDidResume` drive your -"reconnecting" UI; grace expiry arrives as the ordinary `.notConnected` -([DD-9](docs/design-mpc-successor.md)). - -**8. `foundPeer` can fire twice for one peer.** Peer-to-peer Wi-Fi surfaces a +**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. diff --git a/Schemas/signal.fbs b/Schemas/signal.fbs index 7296ea0..9cb96d6 100644 --- a/Schemas/signal.fbs +++ b/Schemas/signal.fbs @@ -67,20 +67,6 @@ table StreamOpen { label:string (id: 0); } -/// The sender is about to be suspended (iOS backgrounding, C-5). Receivers -/// treat the connection loss that follows as a suspension, not a departure, -/// for up to `grace_ms` (clamped by the receiver's configuration): membership -/// survives, and a reconnect within the grace resumes the peer silently. -table Suspend { - grace_ms:ulong (id: 0); -} - -/// The sender is back in the foreground on a connection that SURVIVED the -/// suspension. A reconnect announces itself by connecting; a link that never -/// dropped has no such moment, so it says so explicitly. -table Resume { -} - union SignalBody { Invite, InviteResponse, @@ -88,8 +74,6 @@ union SignalBody { RosterUpdate, TransferOffer, StreamOpen, - Suspend, - Resume, } /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on diff --git a/Sources/MPCCompat/CompatCore.swift b/Sources/MPCCompat/CompatCore.swift index 6a19b5d..4101850 100644 --- a/Sources/MPCCompat/CompatCore.swift +++ b/Sources/MPCCompat/CompatCore.swift @@ -198,19 +198,6 @@ final class CompatCore: @unchecked Sendable { emitState(peer: member.id, state: .connected) case .left(let id), .unreachable(let id): emitState(peer: id, state: .notConnected) - case .suspended(let id): - // MC has no suspended state: the peer stays `.connected` through - // its grace window (expiry arrives as the normal `.left`); apps - // that want "peer backgrounded" UI get the beyond-MC callback. - delegateQueue.async { [weak self] in - guard let self, let session = self.boundSession else { return } - session.delegate?.session(session, peerDidSuspend: id) - } - case .resumed(let member): - delegateQueue.async { [weak self] in - guard let self, let session = self.boundSession else { return } - session.delegate?.session(session, peerDidResume: member.id) - } case .identityChanged: // TOFU continuity warning (FR-21) has no MCSession analog; ignore. break @@ -309,16 +296,6 @@ final class CompatCore: @unchecked Sendable { enqueueOp { await session.stopBrowsing() } } - func announceSuspension(gracePeriod: TimeInterval) { - guard let session = currentSession() else { return } - enqueueOp { await session.announceSuspension(gracePeriod: gracePeriod) } - } - - func resumeSession() { - guard let session = currentSession() else { return } - enqueueOp { await session.resume() } - } - func invite(peerID: PeerID, context: Data?, timeout: TimeInterval) { let session = liveSession() lock.lock() diff --git a/Sources/MPCCompat/MultipeerSession.swift b/Sources/MPCCompat/MultipeerSession.swift index eca7783..0ff8af1 100644 --- a/Sources/MPCCompat/MultipeerSession.swift +++ b/Sources/MPCCompat/MultipeerSession.swift @@ -229,21 +229,6 @@ public final class MultipeerSession: @unchecked Sendable { _connectedPeers.removeAll() stateLock.unlock() } - - // MARK: Background suspension (C-5 — no MCSession analog) - - /// Call from `didEnterBackground`: peers keep this device `.connected` - /// for the grace period instead of dropping it ~5 s after the freeze. - /// Pair with ``resumeFromSuspension()``. - public func announceSuspension(gracePeriod: TimeInterval = 60) { - core?.announceSuspension(gracePeriod: gracePeriod) - } - - /// Call from `didBecomeActive`: re-establish connections that died while - /// the app was frozen. - public func resumeFromSuspension() { - core?.resumeSession() - } } extension PeerID { @@ -269,16 +254,4 @@ public protocol MultipeerSessionDelegate: AnyObject { func session(_ session: MultipeerSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: PeerID) func session(_ session: MultipeerSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, with progress: Progress) func session(_ session: MultipeerSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, at localURL: URL?, withError error: Error?) - - /// Beyond-MC (C-5): the peer announced suspension (backgrounding). It - /// stays `.connected` through its grace window — either `peerDidResume` - /// follows, or `didChange .notConnected` on grace expiry. Default: no-op. - func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) - /// Beyond-MC (C-5): a suspended peer reconnected within grace. Default: no-op. - func session(_ session: MultipeerSession, peerDidResume peerID: PeerID) -} - -extension MultipeerSessionDelegate { - public func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) {} - public func session(_ session: MultipeerSession, peerDidResume peerID: PeerID) {} } diff --git a/Sources/Stormo/Events.swift b/Sources/Stormo/Events.swift index 4bd1e65..43ac505 100644 --- a/Sources/Stormo/Events.swift +++ b/Sources/Stormo/Events.swift @@ -52,16 +52,11 @@ public enum DiscoveryEvent: Sendable { case lost(PeerID) } -/// Session membership events (FR-10, FR-13, FR-14, C-5). +/// Session membership events (FR-10, FR-13, FR-14). public enum MembershipEvent: Sendable { case joined(SessionPeer) case left(PeerID) case unreachable(PeerID) - /// Member announced suspension (C-5): STILL a member — either `.resumed` - /// follows (reconnect within grace) or `.left` does (grace expired). - case suspended(PeerID) - /// Suspended member reconnected within grace; no `.left`/`.joined` pair. - case resumed(SessionPeer) /// A previously known peer reconnected with a different key (TrustPolicy.automatic /// continuity warning, FR-21). case identityChanged(PeerID, previousKeyHash: Data) diff --git a/Sources/Stormo/PeerSession.swift b/Sources/Stormo/PeerSession.swift index 6fb7156..b366e14 100644 --- a/Sources/Stormo/PeerSession.swift +++ b/Sources/Stormo/PeerSession.swift @@ -235,21 +235,6 @@ public actor PeerSession { /// Currently admitted session members (excluding the local peer). public var members: Set { engine.members } - // MARK: Suspension (C-5 — iOS backgrounding) - - /// Announce the coming suspension (call from `didEnterBackground`): - /// members hold membership through the connection loss for `gracePeriod` - /// (each side clamps to its configured maximum). Pair with ``resume()``. - public func announceSuspension(gracePeriod: TimeInterval = 60) async { - run(.command(.suspend(grace: gracePeriod))) - } - - /// Re-establish connections that died while frozen (call from - /// `didBecomeActive`); re-dials use the retained endpoints. - public func resume() async { - run(.command(.resume)) - } - // MARK: Lifecycle /// Leave the session — close all peer connections and clear membership — @@ -380,12 +365,6 @@ public actor PeerSession { case .peerLeft(let peer): membershipContinuation.yield(.left(peer)) - case .peerSuspended(let peer): - membershipContinuation.yield(.suspended(peer)) - - case .peerResumed(let peer): - membershipContinuation.yield(.resumed(SessionPeer(id: peer))) - case .invitationFailed(let peer, let reason): let error: StormoError switch reason { diff --git a/Sources/StormoProtocol/Generated/signal_generated.swift b/Sources/StormoProtocol/Generated/signal_generated.swift index 769ea2a..b7f2342 100644 --- a/Sources/StormoProtocol/Generated/signal_generated.swift +++ b/Sources/StormoProtocol/Generated/signal_generated.swift @@ -20,10 +20,8 @@ public enum Stormo_Wire_SignalBody: UInt8, UnionEnum { case rosterupdate = 4 case transferoffer = 5 case streamopen = 6 - case suspend = 7 - case resume = 8 - public static var max: Stormo_Wire_SignalBody { return .resume } + public static var max: Stormo_Wire_SignalBody { return .streamopen } public static var min: Stormo_Wire_SignalBody { return .none_ } } @@ -342,66 +340,6 @@ public struct Stormo_Wire_StreamOpen: FlatBufferObject, Verifiable { } } -/// The sender is about to be suspended (iOS backgrounding, C-5). Receivers -/// treat the connection loss that follows as a suspension, not a departure, -/// for up to `grace_ms` (clamped by the receiver's configuration): membership -/// survives, and a reconnect within the grace resumes the peer silently. -public struct Stormo_Wire_Suspend: FlatBufferObject, Verifiable { - - static func validateVersion() { FlatBuffersVersion_25_2_10() } - public var __buffer: ByteBuffer! { return _accessor.bb } - private var _accessor: Table - - private init(_ t: Table) { _accessor = t } - public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } - - private enum VTOFFSET: VOffset { - case graceMs = 4 - var v: Int32 { Int32(self.rawValue) } - var p: VOffset { self.rawValue } - } - - public var graceMs: UInt64 { let o = _accessor.offset(VTOFFSET.graceMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } - public static func startSuspend(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 1) } - public static func add(graceMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: graceMs, def: 0, at: VTOFFSET.graceMs.p) } - public static func endSuspend(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } - public static func createSuspend( - _ fbb: inout FlatBufferBuilder, - graceMs: UInt64 = 0 - ) -> Offset { - let __start = Stormo_Wire_Suspend.startSuspend(&fbb) - Stormo_Wire_Suspend.add(graceMs: graceMs, &fbb) - return Stormo_Wire_Suspend.endSuspend(&fbb, start: __start) - } - - public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { - var _v = try verifier.visitTable(at: position) - try _v.visit(field: VTOFFSET.graceMs.p, fieldName: "graceMs", required: false, type: UInt64.self) - _v.finish() - } -} - -/// The sender is back in the foreground on a connection that SURVIVED the -/// suspension. A reconnect announces itself by connecting; a link that never -/// dropped has no such moment, so it says so explicitly. -public struct Stormo_Wire_Resume: FlatBufferObject, Verifiable { - - static func validateVersion() { FlatBuffersVersion_25_2_10() } - public var __buffer: ByteBuffer! { return _accessor.bb } - private var _accessor: Table - - private init(_ t: Table) { _accessor = t } - public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } - - public static func startResume(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 0) } - public static func endResume(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } - - public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { - var _v = try verifier.visitTable(at: position) - _v.finish() - } -} - /// Envelope for every control-plane message (DD-5 rule 1). Size-prefixed on /// the wire. Unknown body variants are ignored-and-logged, never fatal (QA-11). public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { @@ -455,10 +393,6 @@ public struct Stormo_Wire_Signal: FlatBufferObject, Verifiable { try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_TransferOffer.self) case .streamopen: try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_StreamOpen.self) - case .suspend: - try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_Suspend.self) - case .resume: - try ForwardOffset.verify(&verifier, at: pos, of: Stormo_Wire_Resume.self) } }) _v.finish() diff --git a/Sources/StormoProtocol/ProtocolEngine.swift b/Sources/StormoProtocol/ProtocolEngine.swift index 988c712..272020c 100644 --- a/Sources/StormoProtocol/ProtocolEngine.swift +++ b/Sources/StormoProtocol/ProtocolEngine.swift @@ -36,11 +36,6 @@ public struct ProtocolEngine: Sendable { case respondToInvitation(from: PeerID, accept: Bool) case send(Data, to: Recipients, delivery: Delivery) case leave - /// Local app about to background (C-5): announce, and treat members' - /// connection losses as suspensions for `grace`, not departures. - case suspend(grace: TimeInterval) - /// Local app foregrounded: re-dial suspended members with dead links. - case resume } // MARK: Effects @@ -61,10 +56,6 @@ public struct ProtocolEngine: Sendable { public enum TimerKey: Hashable, Sendable { case invitation(PeerID) - /// Grace window (C-5): expiry turns a suspended member into a departure. - case suspension(PeerID) - /// Fixed-rate resume re-dial tick; stops on reconnect or grace expiry. - case resumeRetry(PeerID) } public enum Event: Sendable, Equatable { @@ -72,11 +63,6 @@ public struct ProtocolEngine: Sendable { case invitationFailed(PeerID, reason: InvitationFailure) case peerJoined(PeerID) case peerLeft(PeerID) - /// Member announced suspension (C-5): still a member; no `peerLeft` - /// until the grace window expires. - case peerSuspended(PeerID) - /// Suspended member reconnected within grace; membership never lapsed. - case peerResumed(PeerID) case messageReceived(Data, from: PeerID, delivery: Delivery) /// A member announced a resource transfer on the control stream (FR-17). /// The runtime matches the paired `transferChunk` stream by `id` and @@ -97,19 +83,8 @@ public struct ProtocolEngine: Sendable { public struct Configuration: Sendable { public var invitationTimeout: TimeInterval - /// Ceiling on requested suspension grace — a remote must not park - /// itself as a zombie member forever. - public var maxSuspensionGrace: TimeInterval - /// Fixed interval between resume re-dials (no backoff by design — - /// the loop ends at reconnect or grace expiry). - public var resumeRetryInterval: TimeInterval - - public init(invitationTimeout: TimeInterval = 30, - maxSuspensionGrace: TimeInterval = 120, - resumeRetryInterval: TimeInterval = 1) { + public init(invitationTimeout: TimeInterval = 30) { self.invitationTimeout = invitationTimeout - self.maxSuspensionGrace = maxSuspensionGrace - self.resumeRetryInterval = resumeRetryInterval } } @@ -129,16 +104,6 @@ public struct ProtocolEngine: Sendable { // Zero-copy (DD-5/DD-6): retain the verified Signal (≤64 KB buffer) rather // than copying fields out of it. private var pendingIncoming: [PeerID: Signal] = [:] - // Members under a suspension grace window (C-5); cleared by reconnect or - // by the suspension timer turning them into departures. - private var suspended: Set = [] - // The clamped grace announced by OUR `.suspend`, consumed by `.resume` to - // arm grace timers at wake — the frozen side never runs timers. - private var localSuspensionGrace: TimeInterval? - // Members that came back from a suspension grace hold. A fresh invite - // from one is a relaunched app (new session) rather than a duplicate; - // mesh-formation peers are never suspended, so they are never flagged. - private var reconnectedMembers: Set = [] public init(localPeer: PeerID, configuration: Configuration = Configuration()) { self.localPeer = localPeer @@ -161,18 +126,6 @@ public struct ProtocolEngine: Sendable { case .connectionEstablished(let peer): connections.insert(peer) - // A suspended member reconnecting within grace resumes silently. - if suspended.remove(peer) != nil { - // Held under grace and now back: this is either the SAME - // process resuming, or a relaunched one about to invite. The - // invite handler decides; mesh peers never reach here. - reconnectedMembers.insert(peer) - return [ - .cancelTimer(.suspension(peer)), - .cancelTimer(.resumeRetry(peer)), - .emit(.peerResumed(peer)), - ] - } // If we initiated for a pending invitation, send it now (FR-8: the // invite travels only over the secured connection). The invitation // timer was armed when the invite command was issued — it covers @@ -185,16 +138,13 @@ public struct ProtocolEngine: Sendable { case .connectionClosed(let peer): connections.remove(peer) - reconnectedMembers.remove(peer) var effects: [Effect] = [] if pendingOutgoing.removeValue(forKey: peer) != nil { effects.append(.cancelTimer(.invitation(peer))) effects.append(.emit(.invitationFailed(peer, reason: .connectionLost))) } pendingIncoming.removeValue(forKey: peer) - if suspended.contains(peer) { - // Expected loss: membership survives until grace expiry. - } else if members.remove(peer) != nil { + if members.remove(peer) != nil { // FR-14: one peer's departure never disturbs the rest. effects.append(.emit(.peerLeft(peer))) } @@ -213,30 +163,6 @@ public struct ProtocolEngine: Sendable { .emit(.invitationFailed(peer, reason: .timedOut)), .closeConnection(peer), // FR-9: half-open state cleanup ] - - case .timerFired(.suspension(let peer)): - // Grace expired → departure. Never while the connection is alive: - // a short background can end without the link dropping, and the - // observer side has no resume trigger, only this timer. - guard suspended.remove(peer) != nil else { return [] } - // Link alive: the peer never actually went away (or its Resume was - // lost). Report it back, never leave the app waiting. - if connections.contains(peer) { return [.emit(.peerResumed(peer))] } - guard members.remove(peer) != nil else { return [] } - return [ - .cancelTimer(.resumeRetry(peer)), - .emit(.peerLeft(peer)), - ] - - case .timerFired(.resumeRetry(let peer)): - // Fixed-rate re-dial while resuming; dies with the suspension. - guard suspended.contains(peer), members.contains(peer), - !connections.contains(peer) - else { return [] } - return [ - .connect(to: peer), - .startTimer(.resumeRetry(peer), duration: configuration.resumeRetryInterval), - ] } } @@ -300,56 +226,9 @@ public struct ProtocolEngine: Sendable { connections.removeAll() pendingOutgoing.removeAll() pendingIncoming.removeAll() - suspended.removeAll() // stale suspension timers no-op on fire - reconnectedMembers.removeAll() - localSuspensionGrace = nil return open .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } .map { .closeConnection($0) } - - case .suspend(let grace): - // Mark ALL members suspended and say goodbye — nothing else. The - // process is about to freeze, so NO timers arm here: the marks - // keep queued connection-closed inputs from evicting members, and - // `.resume` (the next time our code provably runs) starts the - // grace clock. - let duration = min(grace, configuration.maxSuspensionGrace) - localSuspensionGrace = duration - let signal = Signal.suspend(graceMs: UInt64(duration * 1000)) - var effects: [Effect] = [] - for member in members.sorted(by: { $0.keyHash.lexicographicallyPrecedes($1.keyHash) }) { - suspended.insert(member) - if connections.contains(member) { - effects.append(.sendSignal(signal, to: member)) - } - } - return effects - - case .resume: - // Wake-up: re-dial suspended members with dead links at a fixed - // rate, bounded by a grace timer that starts NOW (grace runs from - // wake, not from the announce); live links just shed their marks. - let grace = localSuspensionGrace ?? configuration.maxSuspensionGrace - localSuspensionGrace = nil - var effects: [Effect] = [] - let marked = members - .filter(suspended.contains) - .sorted { $0.keyHash.lexicographicallyPrecedes($1.keyHash) } - for member in marked { - if connections.contains(member) { - // The link survived the freeze, so there is no reconnect - // for the peer to observe — say so explicitly or it waits - // forever. - suspended.remove(member) - effects.append(.sendSignal(.resume(), to: member)) - } else { - effects.append(.connect(to: member)) - effects.append(.startTimer( - .resumeRetry(member), duration: configuration.resumeRetryInterval)) - effects.append(.startTimer(.suspension(member), duration: grace)) - } - } - return effects } } @@ -365,23 +244,11 @@ public struct ProtocolEngine: Sendable { // 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. - var effects: [Effect] = [] - if members.contains(peer) { - // Glare during mesh formation (both sides invite at once) is a - // duplicate — drop it. Only a member returning from a grace - // hold is introducing a genuinely new session. - guard reconnectedMembers.remove(peer) != nil else { return [] } - members.remove(peer) - suspended.remove(peer) - effects.append(.cancelTimer(.suspension(peer))) - effects.append(.cancelTimer(.resumeRetry(peer))) - effects.append(.emit(.peerLeft(peer))) - } - guard let inviter = invite.inviter?.peerID else { return effects } + guard !members.contains(peer) else { return [] } + guard let inviter = invite.inviter?.peerID else { return [] } pendingIncoming[peer] = signal // retain the buffer, not a copy let context = invite.hasContext ? Data(invite.context) : nil // app-ownership copy - effects.append(.emit(.invitationReceived(from: inviter, context: context))) - return effects + return [.emit(.invitationReceived(from: inviter, context: context))] case .inviteResponse(let response): guard pendingOutgoing.removeValue(forKey: peer) != nil else { return [] } @@ -436,26 +303,6 @@ public struct ProtocolEngine: Sendable { guard let label = open.label else { return [] } return [.emit(.streamOpened(label: label, from: peer))] - case .suspend(let suspend): - // Membership-gate (DD-6); clamp the requested grace. - guard members.contains(peer) else { return [] } - let duration = min( - TimeInterval(suspend.graceMs) / 1000, configuration.maxSuspensionGrace) - suspended.insert(peer) - return [ - .startTimer(.suspension(peer), duration: duration), - .emit(.peerSuspended(peer)), - ] - - case .resume: - // Only meaningful for a member we are holding under grace. - guard members.contains(peer), suspended.remove(peer) != nil else { return [] } - return [ - .cancelTimer(.suspension(peer)), - .cancelTimer(.resumeRetry(peer)), - .emit(.peerResumed(peer)), - ] - case .unrecognized: // Forward compatibility (QA-11, DD-5 rule 1): ignore-and-log. return [] diff --git a/Sources/StormoProtocol/Signal.swift b/Sources/StormoProtocol/Signal.swift index 43849ac..b1680c7 100644 --- a/Sources/StormoProtocol/Signal.swift +++ b/Sources/StormoProtocol/Signal.swift @@ -11,8 +11,6 @@ public typealias WireCodeConfirm = Stormo_Wire_CodeConfirm public typealias WireRosterUpdate = Stormo_Wire_RosterUpdate public typealias WireTransferOffer = Stormo_Wire_TransferOffer public typealias WireStreamOpen = Stormo_Wire_StreamOpen -public typealias WireSuspend = Stormo_Wire_Suspend -public typealias WireResume = Stormo_Wire_Resume public typealias WireTransferId = Stormo_Wire_TransferId /// A control-plane message: a verified FlatBuffers buffer read **in place** @@ -64,8 +62,6 @@ public struct Signal: @unchecked Sendable, Equatable { case rosterUpdate(WireRosterUpdate) case transferOffer(WireTransferOffer) case streamOpen(WireStreamOpen) - case suspend(WireSuspend) - case resume(WireResume) /// Absent or unrecognized union variant (forward compatibility, QA-11): /// ignored-and-logged, never fatal (DD-5 rule 1). case unrecognized @@ -85,10 +81,6 @@ public struct Signal: @unchecked Sendable, Equatable { return root.body(type: WireTransferOffer.self).map(Body.transferOffer) ?? .unrecognized case .streamopen: return root.body(type: WireStreamOpen.self).map(Body.streamOpen) ?? .unrecognized - case .suspend: - return root.body(type: WireSuspend.self).map(Body.suspend) ?? .unrecognized - case .resume: - return root.body(type: WireResume.self).map(Body.resume) ?? .unrecognized case .none_: return .unrecognized } @@ -144,19 +136,6 @@ public struct Signal: @unchecked Sendable, Equatable { } } - public static func suspend(graceMs: UInt64) -> Signal { - build(.suspend) { fbb in - WireSuspend.createSuspend(&fbb, graceMs: graceMs) - } - } - - public static func resume() -> Signal { - build(.resume) { fbb in - let start = WireResume.startResume(&fbb) - return WireResume.endResume(&fbb, start: start) - } - } - private static func build( _ bodyType: WireSignalBody, _ makeBody: (inout FlatBufferBuilder) -> Offset diff --git a/Tests/MPCCompatTests/MPCCompatE2ETests.swift b/Tests/MPCCompatTests/MPCCompatE2ETests.swift index aec9e88..8a88919 100644 --- a/Tests/MPCCompatTests/MPCCompatE2ETests.swift +++ b/Tests/MPCCompatTests/MPCCompatE2ETests.swift @@ -26,19 +26,16 @@ struct MPCCompatE2ETests { let data: AsyncStream<(Data, PeerID)> let resourceStarts: AsyncStream<(String, PeerID)> let resourceFinishes: AsyncStream<(String, URL?, Error?)> - let suspends: AsyncStream private let statesIn: AsyncStream<(PeerID, MultipeerSession.PeerState)>.Continuation private let dataIn: AsyncStream<(Data, PeerID)>.Continuation private let resourceStartsIn: AsyncStream<(String, PeerID)>.Continuation private let resourceFinishesIn: AsyncStream<(String, URL?, Error?)>.Continuation - private let suspendsIn: AsyncStream.Continuation init() { (states, statesIn) = AsyncStream.makeStream() (data, dataIn) = AsyncStream.makeStream() (resourceStarts, resourceStartsIn) = AsyncStream.makeStream() (resourceFinishes, resourceFinishesIn) = AsyncStream.makeStream() - (suspends, suspendsIn) = AsyncStream.makeStream() } func session(_ session: MultipeerSession, peer peerID: PeerID, didChange state: MultipeerSession.PeerState) { @@ -54,9 +51,6 @@ struct MPCCompatE2ETests { func session(_ session: MultipeerSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: PeerID, at localURL: URL?, withError error: Error?) { resourceFinishesIn.yield((resourceName, localURL, error)) } - func session(_ session: MultipeerSession, peerDidSuspend peerID: PeerID) { - suspendsIn.yield(peerID) - } } /// Auto-accepts every invitation with the supplied session, exactly like @@ -282,54 +276,6 @@ struct MPCCompatE2ETests { sessionA.disconnect() } - /// The observer-side story an app's "peer backgrounded" dialog needs: - /// `peerDidSuspend` fires on the notice, the peer stays `.connected` - /// through the grace window, and expiry arrives as `.notConnected`. - @Test("Suspension surfaces peerDidSuspend, then .notConnected on expiry") - func suspensionSurfacesToDelegate() async throws { - let hub = InMemoryTransport.Hub() - let peerA = PeerID(displayName: "SuspObserver") - let peerB = PeerID(displayName: "SuspSleeper") - let transportA = InMemoryTransport(hub: hub) - let transportB = InMemoryTransport(hub: hub) - - let sessionA = MultipeerSession(peer: peerA, service: Self.service, transport: transportA) - let recorderA = SessionRecorder() - sessionA.delegate = recorderA - let advDelegate = AutoAcceptAdvertiserRecorder(accepting: sessionA) - let advertiser = NearbyServiceAdvertiser( - peer: peerA, discoveryInfo: nil, serviceType: Self.service, transport: transportA) - advertiser.delegate = advDelegate - advertiser.startAdvertisingPeer() - - let sessionB = MultipeerSession(peer: peerB, service: Self.service, transport: transportB) - let recorderB = SessionRecorder() - sessionB.delegate = recorderB - let browserDelegate = BrowserRecorder() - let browser = NearbyServiceBrowser(peer: peerB, serviceType: Self.service, transport: transportB) - browser.delegate = browserDelegate - browser.startBrowsingForPeers() - - var found: PeerID? - for await (peer, _) in browserDelegate.found { found = peer; break } - browser.invitePeer(try #require(found), to: sessionB, withContext: nil, timeout: 10) - _ = try #require(await firstState(recorderA, matching: .connected)) - _ = try #require(await firstState(recorderB, matching: .connected)) - - // B backgrounds with a short grace; A gets the beyond-MC callback. - sessionB.announceSuspension(gracePeriod: 1) - var suspendedPeer: PeerID? - for await peer in recorderA.suspends { suspendedPeer = peer; break } - #expect(suspendedPeer?.displayName == "SuspSleeper") - #expect(sessionA.connectedPeers.count == 1, "suspended peer must stay connected") - - // The link dies while suspended; grace expiry surfaces as the normal - // MC-shaped departure. - sessionB.disconnect() - let departed = try #require(await firstState(recorderA, matching: .notConnected)) - #expect(departed.displayName == "SuspSleeper") - } - @Test("Advertiser, browser, and session with one PeerID share one CompatCore") func sharedCoreForOnePeer() { let peer = PeerID(displayName: "Shared") diff --git a/Tests/StormoProtocolTests/SuspensionTests.swift b/Tests/StormoProtocolTests/SuspensionTests.swift deleted file mode 100644 index f067b60..0000000 --- a/Tests/StormoProtocolTests/SuspensionTests.swift +++ /dev/null @@ -1,272 +0,0 @@ -import Foundation -import Testing - -@testable import StormoProtocol - -/// Tier-1 tests (DD-6) for the suspension grace protocol (C-5): iOS -/// backgrounding kills QUIC connections in ~5 s, so a peer that announced -/// `Suspend` must be treated as suspended — not departed — until its grace -/// window expires or it reconnects. Time is an input: the tests fire timers. -@Suite("ProtocolEngine — suspension grace (C-5)") -struct SuspensionTests { - - static func makePeer(_ name: String, byte: UInt8) -> PeerID { - PeerID( - keyHash: Data([0x12, 0x20]) + Data(repeating: byte, count: 32), - displayName: name) - } - - let alice = Self.makePeer("Alice", byte: 0x0A) - let bob = Self.makePeer("Bob", byte: 0x0B) - - /// Drive `peer` through connect+invite+accept (mirrors the tier-1 helper). - private func engineWithMember(_ local: PeerID, member: PeerID) -> ProtocolEngine { - var engine = ProtocolEngine(localPeer: local) - _ = engine.handle(.connectionEstablished(member)) - _ = engine.handle(.signal(.invite(inviter: member, context: nil), from: member)) - _ = engine.handle(.command(.respondToInvitation(from: member, accept: true))) - return engine - } - - // MARK: The spec repro — without Suspend, connectionClosed = peerLeft - - @Test("Suspend notice: the following connection loss is NOT a departure") - func suspendedCloseKeepsMembership() { - var engine = engineWithMember(alice, member: bob) - - let noticed = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - #expect(noticed == [ - .startTimer(.suspension(bob), duration: 30), - .emit(.peerSuspended(bob)), - ]) - - // The ~5 s QUIC idle timeout closes the connection. Suspended member: - // no peerLeft, membership intact. - let closed = engine.handle(.connectionClosed(bob)) - #expect(closed == []) - #expect(engine.members.contains(bob)) - } - - @Test("Grace expiry turns the suspension into a departure") - func graceExpiryDeparts() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - _ = engine.handle(.connectionClosed(bob)) - - let expired = engine.handle(.timerFired(.suspension(bob))) - #expect(expired == [.cancelTimer(.resumeRetry(bob)), .emit(.peerLeft(bob))]) - #expect(engine.members.isEmpty) - - // The timer is one-shot: a stale second fire is a no-op. - #expect(engine.handle(.timerFired(.suspension(bob))) == []) - } - - @Test("Reconnect within grace resumes silently — membership never lapsed") - func reconnectWithinGraceResumes() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - _ = engine.handle(.connectionClosed(bob)) - - let reconnect = engine.handle(.connectionEstablished(bob)) - #expect(reconnect == [ - .cancelTimer(.suspension(bob)), - .cancelTimer(.resumeRetry(bob)), - .emit(.peerResumed(bob)), - ]) - #expect(engine.members.contains(bob)) - - // A later (stale) grace timer must not evict the resumed member. - #expect(engine.handle(.timerFired(.suspension(bob))) == []) - #expect(engine.members.contains(bob)) - - // And a NORMAL close after the resume is a real departure again. - let closed = engine.handle(.connectionClosed(bob)) - #expect(closed == [.emit(.peerLeft(bob))]) - } - - @Test("Local suspend announces and marks — no timers on a process about to freeze") - func localSuspendAnnounces() { - var engine = engineWithMember(alice, member: bob) - - let effects = engine.handle(.command(.suspend(grace: 60))) - #expect(effects == [.sendSignal(.suspend(graceMs: 60_000), to: bob)]) - - // Our own connection losses while frozen must not evict members. - _ = engine.handle(.connectionClosed(bob)) - #expect(engine.members.contains(bob)) - } - - @Test("Resume re-dials suspended members whose connections died") - func resumeRedials() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.command(.suspend(grace: 60))) - _ = engine.handle(.connectionClosed(bob)) - - // Wake-up arms both clocks: the fixed-rate re-dial and the grace, - // which runs from NOW (the frozen process never ran timers). - let effects = engine.handle(.command(.resume)) - #expect(effects == [ - .connect(to: bob), - .startTimer(.resumeRetry(bob), duration: 1), - .startTimer(.suspension(bob), duration: 60), - ]) - - // The re-dial completing resumes the member and stops the loop. - let reconnected = engine.handle(.connectionEstablished(bob)) - #expect(reconnected == [ - .cancelTimer(.suspension(bob)), - .cancelTimer(.resumeRetry(bob)), - .emit(.peerResumed(bob)), - ]) - } - - @Test("Resume re-dial ticks at a fixed rate until reconnect") - func resumeRetriesAtFixedRate() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.command(.suspend(grace: 60))) - _ = engine.handle(.connectionClosed(bob)) - _ = engine.handle(.command(.resume)) - - // The dial fails (radio not back yet): membership held, and each - // tick re-dials and re-arms — fixed rate, no backoff. - _ = engine.handle(.connectionClosed(bob)) - #expect(engine.members.contains(bob)) - let tick = engine.handle(.timerFired(.resumeRetry(bob))) - #expect(tick == [ - .connect(to: bob), - .startTimer(.resumeRetry(bob), duration: 1), - ]) - - // Reconnect stops the loop: a stale tick is a no-op. - _ = engine.handle(.connectionEstablished(bob)) - #expect(engine.handle(.timerFired(.resumeRetry(bob))) == []) - } - - @Test("Resume re-dial dies with the suspension") - func resumeRetryStopsOnExpiryAndLeave() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.command(.suspend(grace: 60))) - _ = engine.handle(.connectionClosed(bob)) - _ = engine.handle(.command(.resume)) - - // Grace expiry evicts the member; the next tick must go quiet. - _ = engine.handle(.timerFired(.suspension(bob))) - #expect(engine.handle(.timerFired(.resumeRetry(bob))) == []) - - // Same after leave. - var engine2 = engineWithMember(alice, member: bob) - _ = engine2.handle(.command(.suspend(grace: 60))) - _ = engine2.handle(.connectionClosed(bob)) - _ = engine2.handle(.command(.resume)) - _ = engine2.handle(.command(.leave)) - #expect(engine2.handle(.timerFired(.resumeRetry(bob))) == []) - } - - @Test("Resume on a surviving link ANNOUNCES — the peer has no reconnect to see") - func resumeWithLiveConnection() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.command(.suspend(grace: 60))) - - // No close happened (short background). The peer is holding us under - // grace and will never observe a reconnect, so say we are back. - #expect(engine.handle(.command(.resume)) == [.sendSignal(.resume(), to: bob)]) - #expect(engine.members.contains(bob)) - - // A genuine close after that is a real departure again. - #expect(engine.handle(.connectionClosed(bob)) == [.emit(.peerLeft(bob))]) - } - - @Test("Grace expiry on a live link resumes the member, never departs it") - func expiryWithLiveConnectionResumes() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - - // The link never dropped and the Resume never arrived (lost, or the - // peer never called resume). Expiry must free the app from waiting, - // not depart a member we are demonstrably connected to. - #expect(engine.handle(.timerFired(.suspension(bob))) == [.emit(.peerResumed(bob))]) - #expect(engine.members.contains(bob)) - - // Suspension shed: the next close is a normal departure. - #expect(engine.handle(.connectionClosed(bob)) == [.emit(.peerLeft(bob))]) - } - - @Test("A remote's requested grace is clamped to the configured maximum") - func graceClampedToMaximum() { - var engine = engineWithMember(alice, member: bob) - let effects = engine.handle(.signal(.suspend(graceMs: 3_600_000), from: bob)) - #expect(effects == [ - .startTimer(.suspension(bob), duration: 120), - .emit(.peerSuspended(bob)), - ]) - } - - @Test("Suspend from a non-member is ignored (membership gate, DD-6)") - func suspendFromNonMemberIgnored() { - var engine = ProtocolEngine(localPeer: alice) - _ = engine.handle(.connectionEstablished(bob)) - #expect(engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) == []) - } - - @Test("Leave clears suspensions — stale grace timers no-op") - func leaveClearsSuspensions() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - _ = engine.handle(.command(.leave)) - #expect(engine.handle(.timerFired(.suspension(bob))) == []) - #expect(engine.members.isEmpty) - } - - @Test("Resume signal clears a grace hold on a link that never dropped") - func inboundResumeClearsHold() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 30_000), from: bob)) - - let effects = engine.handle(.signal(.resume(), from: bob)) - #expect(effects == [ - .cancelTimer(.suspension(bob)), - .cancelTimer(.resumeRetry(bob)), - .emit(.peerResumed(bob)), - ]) - #expect(engine.members.contains(bob)) - - // Not holding anything: a stray Resume is inert. - #expect(engine.handle(.signal(.resume(), from: bob)) == []) - } - - /// Device bug: the remote app was killed and relaunched while the camera - /// still held it (grace, or an unnoticed death). Identity is persisted, so - /// it re-invited as the SAME PeerID — and the engine dropped the invite as - /// a duplicate member, so the camera never answered and the relaunched app - /// could never reconnect. - @Test("A relaunched member's fresh invite is a rejoin, not a duplicate") - func reinviteFromMemberIsRejoin() { - var engine = engineWithMember(alice, member: bob) - _ = engine.handle(.signal(.suspend(graceMs: 60_000), from: bob)) - _ = engine.handle(.connectionClosed(bob)) - #expect(engine.members.contains(bob), "held under grace") - - // The relaunched process dials in and invites afresh. - _ = engine.handle(.connectionEstablished(bob)) - let effects = engine.handle(.signal(.invite(inviter: bob, context: nil), from: bob)) - #expect(effects.contains(.emit(.peerLeft(bob))), "the old session really did end") - #expect(effects.contains(.emit(.invitationReceived(from: bob, context: nil))), - "…and the newcomer must be offered to the app") - - // Accepting completes the rejoin. - let accepted = engine.handle(.command(.respondToInvitation(from: bob, accept: true))) - #expect(accepted.contains(.emit(.peerJoined(bob)))) - #expect(engine.members.contains(bob)) - } - - @Test("Suspend signal round-trips the wire codec") - func suspendSignalRoundTrip() throws { - let signal = Signal.suspend(graceMs: 45_000) - let decoded = try SignalCodec.decode(signal.encoded) - guard case .suspend(let view) = decoded.body else { - Issue.record("expected .suspend body, got \(decoded.body)") - return - } - #expect(view.graceMs == 45_000) - } -} diff --git a/Tests/StormoTests/SuspensionRuntimeTests.swift b/Tests/StormoTests/SuspensionRuntimeTests.swift deleted file mode 100644 index 0d1a2cd..0000000 --- a/Tests/StormoTests/SuspensionRuntimeTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import Testing - -import Stormo -import StormoTestKit - -/// Tier-2 runtime tests for the suspension grace protocol (C-5): the full -/// announce → freeze (transport-level kill, no goodbye) → hold → resume/expiry -/// loop over `InMemoryTransport`, using `KillSwitchTransport` to sever links -/// the way iOS suspension does — silently. -@Suite("Suspension grace over InMemoryTransport") -struct SuspensionRuntimeTests { - - /// Bounded wait for the first membership event matching `predicate` — - /// a missing event must fail the test, not hang the suite. - private func firstMembership( - of stream: AsyncStream, - within seconds: TimeInterval, - where predicate: @escaping @Sendable (MembershipEvent) -> Bool - ) async -> MembershipEvent? { - await withTaskGroup(of: MembershipEvent?.self) { group in - group.addTask { - for await event in stream where predicate(event) { return event } - return nil - } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil - } - let winner = await group.next() ?? nil - group.cancelAll() - return winner - } - } - - @Test("Backgrounded peer suspends, survives the silent link kill, and resumes") - func suspendKillResume() async throws { - let hub = InMemoryTransport.Hub() - // peer-0 is the backgrounder: it invited peer-1 (formMesh: i < j), so - // it retains the endpoint its resume re-dial needs. - let killable = KillSwitchTransport(base: InMemoryTransport(hub: hub)) - let backgrounder = PeerSession( - identity: PeerIdentity(name: "peer-0"), service: "_susp._udp", transport: killable) - let observer = PeerSession( - identity: PeerIdentity(name: "peer-1"), service: "_susp._udp", - transport: InMemoryTransport(hub: hub)) - _ = try await formMesh([backgrounder, observer]) - let backgrounderID = await backgrounder.identity.id - let observerMembership = await observer.membership - - // Wait for the notice before killing — the freeze must not race the - // signal onto a dead link. - await backgrounder.announceSuspension(gracePeriod: 8) - let suspendedEvent = await firstMembership(of: observerMembership, within: 5) { - if case .suspended(let id) = $0 { return id == backgrounderID } - return false - } - #expect(suspendedEvent != nil, "observer must see the suspension notice") - - // iOS freezes the app: every link dies silently, no protocol goodbye. - await killable.kill() - try await Task.sleep(nanoseconds: 300_000_000) - - // Grace holds membership on BOTH sides. - #expect(await observer.members.count == 1, "suspended member must survive the kill") - #expect(await backgrounder.members.count == 1, "frozen side keeps its members too") - - await backgrounder.resume() - let resumedEvent = await firstMembership(of: observerMembership, within: 5) { - if case .resumed(let member) = $0 { return member.id == backgrounderID } - return false - } - #expect(resumedEvent != nil, "observer must see the resume") - - // The revived link carries traffic. - let observerInbox = await observer.messages - try await backgrounder.send(Data([0xB0]), to: .all, delivery: .reliable) - let got = await withTaskGroup(of: Data?.self) { group in - group.addTask { - for await message in observerInbox { return message.payload } - return nil - } - group.addTask { - try? await Task.sleep(nanoseconds: 5_000_000_000) - return nil - } - let winner = await group.next() ?? nil - group.cancelAll() - return winner - } - #expect(got == Data([0xB0]), "post-resume send must reach the observer") - - await backgrounder.disconnect() - await observer.disconnect() - } - - @Test("Grace expiry without a resume becomes a normal departure") - func graceExpiryDeparts() async throws { - let hub = InMemoryTransport.Hub() - let killable = KillSwitchTransport(base: InMemoryTransport(hub: hub)) - let backgrounder = PeerSession( - identity: PeerIdentity(name: "peer-0"), service: "_suspx._udp", transport: killable) - let observer = PeerSession( - identity: PeerIdentity(name: "peer-1"), service: "_suspx._udp", - transport: InMemoryTransport(hub: hub)) - _ = try await formMesh([backgrounder, observer]) - let backgrounderID = await backgrounder.identity.id - let observerMembership = await observer.membership - - await backgrounder.announceSuspension(gracePeriod: 1) - let suspendedEvent = await firstMembership(of: observerMembership, within: 5) { - if case .suspended(let id) = $0 { return id == backgrounderID } - return false - } - #expect(suspendedEvent != nil, "observer must see the suspension notice") - await killable.kill() - - // No resume: after the ~1 s grace the suspension becomes .left. - let leftEvent = await firstMembership(of: observerMembership, within: 6) { - if case .left(let id) = $0 { return id == backgrounderID } - return false - } - #expect(leftEvent != nil, "grace expiry must surface as a departure") - #expect(await observer.members.isEmpty) - - await backgrounder.disconnect() - await observer.disconnect() - } -} diff --git a/docs/design-mpc-successor.md b/docs/design-mpc-successor.md index 20ef246..b733c14 100644 --- a/docs/design-mpc-successor.md +++ b/docs/design-mpc-successor.md @@ -285,19 +285,6 @@ The obvious alternative to a bespoke protocol is libp2p (the IPFS-lineage modula **Adopted from libp2p instead:** the PeerID identity encoding (multihash of the encoded public key, CIDv1 text representation) SHALL replace the ad-hoc SHA-256-of-raw-key format before the wire protocol freezes — near-zero cost now, and it keeps a future libp2p bridge (post-1.0 internet reach via relays) identity-compatible. Revisit trigger: if post-1.0 scope expands to internet-wide P2P (NAT traversal/relays), evaluate bridging to libp2p protocols rather than reinventing that tier. -### DD-9: Suspension grace — announced backgrounding is not a departure (C-5) — **adopted** - -iOS suspension freezes the process, PINGs stop, and the peer's 5 s idle timeout kills the QUIC connection — nothing in userspace can prevent that (MPC survived backgrounding only because system daemons owned its links). Stormo therefore makes suspension explicit rather than trying to keep sockets alive: - -- **Wire:** a `Suspend { grace_ms }` control signal, sent by the backgrounding app (`PeerSession.announceSuspension(gracePeriod:)` from `didEnterBackground`) over the still-alive connection — milliseconds of work, well inside iOS's background transition window. -- **Receiver:** membership-gated; the requested grace clamps to `Configuration.maxSuspensionGrace` (120 s default) so a remote cannot park itself as a zombie member. The member is marked suspended and a `suspension` timer arms. Its subsequent `connectionClosed` emits **no `peerLeft`** — membership survives. Timer expiry turns the suspension into an ordinary departure; expiry never evicts a member whose connection is alive (a short background can end without the link dropping, and the observer's only signal is this timer). -- **Sender:** marks all its members suspended and says goodbye — nothing else. A process about to freeze arms **no timers** (frozen code doesn't run, and wall-clock deadlines would all fire at once on thaw); the marks only keep its queued connection-closed inputs from evicting members. -- **Resume:** `PeerSession.resume()` (from `didBecomeActive`) is the sender's next provable execution point, so its grace clock starts **here**: for each suspended member with a dead link it arms the grace timer and re-dials via the retained endpoint at a **fixed rate** (`resumeRetryInterval`, 1 s, deliberately no backoff — the loop is bounded by reconnect or that grace). A reconnect within grace cancels the timers and emits `peerResumed`/`.resumed`: membership never lapsed, no re-invitation, no roster churn. Resume is always a **fresh QUIC connection** (rebuild-on-resume, not connection migration): the dead connection object is discarded normally, and the grace is membership bookkeeping above the transport, never a socket-lifetime trick. -- **Trust boundary:** suspension state enters only through an explicit, authenticated in-session `Suspend` or the local app's own command. A silent drop with no notice is still a departure in ~5 s — walk-away detection (QA-5) is unchanged. -- **MPCCompat:** MC has no suspended state, so a suspended peer simply stays `.connected` through its grace (expiry arrives as the normal `.notConnected`); apps that want "peer backgrounded" UI use the beyond-MC `peerDidSuspend`/`peerDidResume` delegate callbacks. - -Engine mechanics live in `ProtocolEngine` (`suspended` set, `TimerKey.suspension`/`.resumeRetry`), asserted by tier-1 spec tests (`SuspensionTests`) and the tier-2 announce→kill→resume/expiry loop (`SuspensionRuntimeTests`). - --- ## 7. Architecture Overview (module view) @@ -565,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** | Hardware validation of DD-9 over AWDL: does the thaw-time re-dial reconnect within the grace on real radios (AWDL re-establishment latency after suspension)? | Device testing with app lifecycle scripting | DD-9 resume loop reconnects on-device across short/long backgrounds | +| **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 | ---