Skip to content

Suspension grace (C-5): backgrounded peers suspend and resume, not vanish - #16

Merged
darioalessandro merged 11 commits into
mainfrom
feat/background-suspend-grace
Jul 29, 2026
Merged

Suspension grace (C-5): backgrounded peers suspend and resume, not vanish#16
darioalessandro merged 11 commits into
mainfrom
feat/background-suspend-grace

Conversation

@darioalessandro

Copy link
Copy Markdown
Member

Stacked on #15 (auto-retargets to main when it merges). Default version bump: revision.

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

  • Wire: new Suspend { grace_ms } signal — append-only union addition, no existing field/id touched.
  • Engine (sans-I/O, deterministic): on a suspend notice the member is marked suspended and a .suspension(peer) grace timer arms. Its connection loss then emits no peerLeft — membership survives. Reconnect within grace → timer cancelled, peerResumed, membership never lapsed. Expiry → normal peerLeft. 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).
  • Local side: .suspend(grace) announces to connected members and marks all members (our own connection-closed inputs while frozen must not evict them); .resume re-dials members with dead links via retained endpoints and sheds marks on live ones.

Surfaces

  • 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 ordinary .notConnected. MultipeerSession.announceSuspension() / resumeFromSuspension() for apps to call from didEnterBackground / didBecomeActive.

Tests (spec-first)

  • 11 tier-1 engine tests (SuspensionTests) — the core spec repro is "suspend notice → connection loss ≠ departure", impossible before this change; timers fire when the test says so.
  • 2 tier-2 runtime tests (SuspensionRuntimeTests) — full loop over InMemoryTransport with KillSwitchTransport severing links silently: announce → kill → membership held on both sides → resume → traffic flows; and announce → kill → no resume → .left on expiry.
  • Test-double contract fix the tier-2 tests caught: 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 and resume() re-dialed nothing. The real QUIC driver yields .closed on both ends; the double now matches.
  • Full suite: 89 tests / 21 suites green.

Not in this PR

  • Remote-shutter wiring (call the two methods from app lifecycle + "peer backgrounded" UI instead of popping to the scanner) — app-side PR after this merges and the pin bumps.
  • Hardware validation over AWDL (S-5 spike): the 5 s idle-kill and thaw-time reconnect need a two-device run; loopback can't fake process freezing.

🤖 Generated with Claude Code

Base automatically changed from fix/rescan-discovery-lifecycle to main July 29, 2026 05:22
darioalessandro and others added 5 commits July 28, 2026 22:47
…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
darioalessandro force-pushed the feat/background-suspend-grace branch from 01f4f4b to 579952d Compare July 29, 2026 05:49
darioalessandro and others added 6 commits July 28, 2026 22:53
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>
@darioalessandro
darioalessandro merged commit 76093d9 into main Jul 29, 2026
4 checks passed
@darioalessandro
darioalessandro deleted the feat/background-suspend-grace branch July 29, 2026 08:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant