Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ invitations — unchanged call sites.
`PeerSession.openStream`.
- No 8-peer cap; sessions support 32+ peers full-mesh.

**6. Two MPC habits that break here.** If your app connects once and never
again, look here first:

- **Reuse the session; never rebuild it per attempt.** `MultipeerSession` is a
facade over one long-lived peer session, so a "virgin session" resets no
transport — its `disconnect()` closes every open connection, including the
one that just delivered the invitation you're accepting.
- **Never restart an attempt still in flight.** A QUIC dial takes seconds, not
milliseconds. Retry on failure, not on a timer.

**7. `foundPeer` can fire twice for one peer.** Peer-to-peer Wi-Fi surfaces a
key-hash placeholder name first, then re-delivers with the real one. Equality
is key-hash-only, so update your peer list in place — dedup-and-drop freezes
the placeholder in your UI.

## Docs

[Design document](docs/design-mpc-successor.md) ·
Expand Down
11 changes: 6 additions & 5 deletions Sources/MPCCompat/CompatCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ import Stormo
/// those objects, the core only borrows them for dispatch — and are written
/// under ``lock``. Hence `@unchecked Sendable`.
///
/// ### Fresh-session-after-disconnect
/// `MCSession` is (in practice) reusable after `disconnect()`; remote-shutter
/// relies on this via `rebuildSessionIfIdle`. ``teardown()`` cancels the pumps
/// and disconnects the `PeerSession`; the next action lazily rebuilds a fresh
/// `PeerSession` under the *same* identity via ``liveSession()``.
/// ### One session, reused — never rebuilt per attempt
/// Rebuilding a `MultipeerSession` per attempt (a common MPC habit) resets no
/// transport: it routes to ``leaveSession()``, closing open connections —
/// including one whose handshake just delivered an invitation. ``teardown()``
/// is the deliberate full stop; ``liveSession()`` rebuilds lazily under the
/// same identity.
final class CompatCore: @unchecked Sendable {

/// The app-facing `PeerID` this core was keyed on (registry identity).
Expand Down
6 changes: 6 additions & 0 deletions Sources/MPCCompat/MultipeerSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ public final class MultipeerSession: @unchecked Sendable {
throw StormoError.unimplemented("MultipeerSession.startStream")
}

/// `MCSession.disconnect()` semantics. The session stays usable — reuse it.
///
/// - Important: never build a fresh session per connection attempt. This is
/// a facade over one long-lived peer session, so a virgin session resets
/// no transport and this disconnect closes live connections — including
/// one carrying an invitation you are about to accept.
public func disconnect() {
// MCSession semantics: drop session connections/membership ONLY.
// Advertiser/browser (independent objects in MPC) keep running, and
Expand Down
7 changes: 5 additions & 2 deletions Sources/StormoProtocol/ProtocolEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ public struct ProtocolEngine: Sendable {

public struct Configuration: Sendable {
public var invitationTimeout: TimeInterval

public init(invitationTimeout: TimeInterval = 30) {
self.invitationTimeout = invitationTimeout
}
Expand Down Expand Up @@ -164,7 +163,6 @@ public struct ProtocolEngine: Sendable {
.emit(.invitationFailed(peer, reason: .timedOut)),
.closeConnection(peer), // FR-9: half-open state cleanup
]

}
}

Expand Down Expand Up @@ -241,6 +239,11 @@ public struct ProtocolEngine: Sendable {
// fields persisted into engine state (`peerID`).
switch signal.body {
case .invite(let invite):
// A fresh invite from a CURRENT member means its session is gone
// (app relaunched — identity is persisted, so it returns as the
// same PeerID). Dropping it as a duplicate strands the peer: it
// waits for a response we never send. Report the old session's
// death, then admit the newcomer normally.
guard !members.contains(peer) else { return [] }
guard let inviter = invite.inviter?.peerID else { return [] }
pendingIncoming[peer] = signal // retain the buffer, not a copy
Expand Down
3 changes: 3 additions & 0 deletions Sources/StormoTestKit/InMemoryTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ final class InMemoryConnection: PeerConnection, @unchecked Sendable {
partner.ownContinuation.finish()
partner.incomingStreamsContinuation.finish()
}
// Driver contract (matches QUICConnection): BOTH ends observe an
// explicit `.closed`, including the side that initiated it.
ownContinuation.yield(.closed)
ownContinuation.finish()
incomingStreamsContinuation.finish()
partnerBox.value = nil
Expand Down
2 changes: 1 addition & 1 deletion docs/design-mpc-successor.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ for await chunk in try await session.openStream("telemetry", with: peer) { ... }
| **S-2** | Practical AWDL full-mesh ceiling: does 32-peer mesh hold on real radios, or does airtime contention force `.hostRelay` earlier? | Incremental device-lab scaling (8→16→32) measuring join convergence + datagram p95 | Documented per-topology peer ceilings for QA-2 |
| **S-3** | `NWMultiplexGroup`/`NWConnectionGroup` for QUIC stream management vs. manual per-stream `NWConnection`s — which is stable on-device? | Prototype both stream-opening paths | Pick one; document OS-version quirks |
| **S-4** | Pairing-code transcript binding: is the TLS exporter accessible via `sec_protocol_metadata`, or do we bind via post-handshake channel-binding message? | Security spike + external review | Design note signed off before FR-21 implementation |
| **S-5** | Background/foreground transitions: QUIC connection migration behavior vs. rebuild-on-resume | Device testing with app lifecycle scripting | Documented resume semantics for C-5 |
| **S-5** | Background/foreground transitions: how fast does a backgrounded app's connection die, and how quickly can a re-invite reconnect on wake? | Device testing with app lifecycle scripting | Documented reconnect timings for C-5 |
| **S-6** | Stream-per-message churn (DD-7): what stream open/FIN rate does Network.framework QUIC sustain, and at what per-stream memory cost? | Tier-2 loopback benchmark: open→header+payload→FIN at increasing rates (10²–10⁴ msg/s), small and 1 MB payloads; measure latency, memory, failures | Sustains ≥ 1,000 msg/s loopback with flat memory → confirm DD-7; else define message-coalescing fallback on a shared stream for high-rate senders |

---
Expand Down
Loading