Skip to content

Fix the stuck-rescan lifecycle: browse dedup, ghost listeners, op ordering - #15

Merged
darioalessandro merged 4 commits into
mainfrom
fix/rescan-discovery-lifecycle
Jul 29, 2026
Merged

Fix the stuck-rescan lifecycle: browse dedup, ghost listeners, op ordering#15
darioalessandro merged 4 commits into
mainfrom
fix/rescan-discovery-lifecycle

Conversation

@darioalessandro

@darioalessandro darioalessandro commented Jul 29, 2026

Copy link
Copy Markdown
Member

The field bug

Remote-shutter with the Stormo swap: after a session disconnect the camera returns to its advertising screen and every subsequent connect fails — until the camera backs out to the role picker (full teardown) and re-enters. Repro-first: each fix below has a test that failed before it.

Error-mode mapping (what actually breaks in the field)

The camera's screen revisit cycles its advertiser (stop → new compat advertiser → start), re-registering the same Bonjour instance name with milliseconds of downtime. Over AWDL the monitor usually never observes that flap (goodbyes are lossy; mDNS caching bridges the gap), so no .lost fires and its discovery state goes stale. The monitor's only other refresh path — restarting its browse on the scanning screen — was permanently blind (bug 1). Result: stale invites forever. The role-picker "fix" worked because the camera stayed gone long enough for a real .lost → re-found to refresh the monitor through its live browsers. Loopback can't reproduce the camera-side half (instant, lossless mDNS) — the deterministic repro asserts the monitor-side blindness at the transport layer; the camera-side hazards (2, 3) are fixed and pinned where they are deterministic.

Three root causes

  1. Transport-lifetime browse dedupQUICTransport.seenPeers survived browse restarts, so a new browse session never re-emitted .found for a still-advertising peer. Over AWDL the plain browse is the only source of finds (failure mode 8) — TXT enrichment's per-stream .updated masked this on loopback, which is why nothing caught it. Dedup state is now per browse session, exactly like the enrichment set already was.
    Repro: BrowseRestartTests.browseRestartRefindsAdvertisedPeer (failed 15s-timeout before, passes ~1s after).

  2. Ghost listeners on advertising restartstartAdvertising overwrote the listener box without cancelling the previous listener, stranding a live Bonjour registration: a ghost peer that browsers keep finding but that can never accept. Listener + TLS identity + advertised peer now live in one Locked slot and swap as a unit; the displaced triple is cancelled/disposed by whoever displaced it, under any call ordering (including concurrent starts). Failure during the ready-wait unpublishes and retires its own pair.

  3. Unordered lifecycle bridging — CompatCore turned MC's synchronous, implicitly-ordered calls (stopBrowsingForPeers(); startBrowsingForPeers() — remote-shutter's exact scanning-screen revisit) into independent unstructured Tasks racing through a reentrant actor: stop/start could invert or overlap, stranding a radio or ending up with none. Lifecycle ops (advertise/browse start+stop, leave, teardown's disconnect) now chain behind the previous op.
    Repro for 2+3 together: MPCCompatE2ETests.advertiserRestartLeavesNoGhostListener (start, start, stop must end with the Bonjour record withdrawn — needs both the atomic swap and ordered ops).

MC-fidelity fix the tests surfaced: lostPeer now delivers the same enriched PeerID that foundPeer delivered, not the transport's name-only AWDL placeholder — real MC hands back the identical MCPeerID both times, and PeerID equality is key-hash-only, so an app matching rows by display name would never have seen the departure.

Flow coverage

  • MPCCompatE2ETests.rescanAfterDisconnectOverQUIC — monitor's scanning-screen revisit end to end (connect → disconnect → stop+start browse → re-find → reconnect) over real QUIC+Bonjour. Doc comment records the TXT-masking trap.
  • MPCCompatE2ETests.cameraReadvertiseAfterDisconnectOverQUIC — the camera's app-exact advertiser cycle plus round-2 invite accepted with freshly rebuilt sessions on both sides (rebuildSessionIfIdle). Coverage, not a repro: passes on loopback even pre-fix (see mapping above); guards the cycle + fresh-session accept path.

Testing

  • swift test: 76 tests / 19 suites green, repeatedly (was 72 — 4 new).
  • The previously-existing reinviteAfterDisconnectOverQUIC (retained-endpoint re-invite) still passes — leaveSession() semantics untouched.
  • Decisive validation is two-device hardware over AWDL: disconnect → both screens rescan → reconnect, plus camera-cycle-only revisit.

🤖 Generated with Claude Code

darioalessandro and others added 3 commits July 28, 2026 19:56
…ering

Remote-shutter field bug: after a session disconnect both devices return
to the scanning screen and restart discovery on the same compat objects,
and no connection ever succeeds again until the screen is rebuilt. Three
root causes, each repro'd by a test that failed before its fix:

- QUICTransport kept the plain-browse dedup set (`seenPeers`) for the
  transport's lifetime, so a restarted browse session never re-emitted
  `.found` for a peer still advertising. Over AWDL the plain browse is
  the ONLY source of finds (TXT enrichment masked this on loopback), so
  the scanner stayed empty forever. Dedup state is now per browse
  session, like the enrichment set already was.
  (BrowseRestartTests.browseRestartRefindsAdvertisedPeer)

- `startAdvertising` overwrote the listener box without cancelling the
  previous listener, stranding a live Bonjour registration — a ghost
  peer browsers keep finding but that can never accept. Listener + TLS
  identity + advertised peer now swap as one atomic unit; the displaced
  triple is cancelled/disposed by whoever displaced it, under any call
  ordering. (MPCCompatE2ETests.advertiserRestartLeavesNoGhostListener)

- CompatCore bridged MC's synchronous, implicitly-ordered lifecycle
  calls into unordered unstructured Tasks, so back-to-back
  `stop(); start()` could invert or overlap across the reentrant actor
  and end up with a stranded radio — or none. Lifecycle ops now chain
  behind the previous op. (Same ghost test: start,start,stop is only
  deterministic once ops are ordered.)

MC-fidelity fix surfaced by the tests: `lostPeer` now delivers the same
enriched PeerID `foundPeer` delivered, not the transport's name-only
AWDL placeholder (PeerID equality is key-hash-only, so apps matching
rows by display name would never see the departure).

Also covers the full scanning-screen revisit flow end to end
(MPCCompatE2ETests.rescanAfterDisconnectOverQUIC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…essions)

Mirrors remote-shutter's camera returning to its advertising screen after
a disconnect: stopAdvertisingPeer, a NEW compat advertiser on the same
core, startAdvertisingPeer back to back, then a re-invite accepted with a
freshly rebuilt MultipeerSession on both sides (rebuildSessionIfIdle).

Coverage, not a repro: this passes on loopback even pre-fix — the field
failure needs AWDL's lossy record-flap propagation (the monitor misses
the camera's re-registration and its only refresh path, a browse
restart, was the deduped one fixed here). Guards the cycle + fresh-
session accept path against regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re-fire

- StormoCLI's failure-mode-9 comment claimed 5 s PINGs / 15 s idle; the
  authoritative values are 1 s PING / 5 s idle (QUICTLS.swift) — anyone
  reasoning about the background-death window from the CLI was off 3x.
- NearbyServiceBrowserDelegate now documents the MC delta that bit
  remote-shutter's scanner: foundPeer re-fires for the same peer when TXT
  enrichment upgrades an AWDL name-only find (placeholder display name),
  and PeerID equality is key-hash-only — callers must update stored peers
  in place, never dedup-and-drop. lostPeer's same-PeerID guarantee is
  documented alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@darioalessandro
darioalessandro merged commit 12eb6af into main Jul 29, 2026
4 checks passed
@darioalessandro
darioalessandro deleted the fix/rescan-discovery-lifecycle branch July 29, 2026 05:22
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