Suspension grace (C-5): backgrounded peers suspend and resume, not vanish - #16
Merged
Conversation
…nish
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
darioalessandro
force-pushed
the
feat/background-suspend-grace
branch
from
July 29, 2026 05:49
01f4f4b to
579952d
Compare
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The field bug (remote-shutter bug 2)
Backgrounding the app disconnects peers within ~5 s. MC survived backgrounding because system daemons owned the AWDL link; Stormo's QUIC runs in-process — the frozen app stops its 1 s PINGs and the peer's 5 s idle timeout fires (
QUICTLS.swift, deliberately tuned for walk-away detection). No background mode restores MC's behavior, so this implements the design's C-5 constraint: suspension/resume made explicit, graceful suspend + fast resume instead of trying to keep the link up.Protocol
Suspend { grace_ms }signal — append-only union addition, no existing field/id touched..suspension(peer)grace timer arms. Its connection loss then emits nopeerLeft— membership survives. Reconnect within grace → timer cancelled,peerResumed, membership never lapsed. Expiry → normalpeerLeft. Requested grace clamps to a configured max (120 s) so a remote can't park as a zombie member. Expiry never evicts a member whose link is still alive (short backgrounds can end without the connection dropping, and the observer has no resume trigger — only the timer)..suspend(grace)announces to connected members and marks all members (our own connection-closed inputs while frozen must not evict them);.resumere-dials members with dead links via retained endpoints and sheds marks on live ones.Surfaces
PeerSession.announceSuspension(gracePeriod:)/resume(); newMembershipEvent.suspended/.resumed..connected(MC has no suspended state); grace expiry arrives as the ordinary.notConnected.MultipeerSession.announceSuspension()/resumeFromSuspension()for apps to call fromdidEnterBackground/didBecomeActive.Tests (spec-first)
SuspensionTests) — the core spec repro is "suspend notice → connection loss ≠ departure", impossible before this change; timers fire when the test says so.SuspensionRuntimeTests) — full loop overInMemoryTransportwithKillSwitchTransportsevering links silently: announce → kill → membership held on both sides → resume → traffic flows; and announce → kill → no resume →.lefton expiry.InMemoryConnection.close()notified only the partner — the closing side's event stream just finished, so the killer's own session still counted the link as alive andresume()re-dialed nothing. The real QUIC driver yields.closedon both ends; the double now matches.Not in this PR
🤖 Generated with Claude Code