Fix the stuck-rescan lifecycle: browse dedup, ghost listeners, op ordering - #15
Merged
Merged
Conversation
…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>
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 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
.lostfires 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
Transport-lifetime browse dedup —
QUICTransport.seenPeerssurvived browse restarts, so a new browse session never re-emitted.foundfor a still-advertising peer. Over AWDL the plain browse is the only source of finds (failure mode 8) — TXT enrichment's per-stream.updatedmasked 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).Ghost listeners on advertising restart —
startAdvertisingoverwrote 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 oneLockedslot 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.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:
lostPeernow delivers the same enrichedPeerIDthatfoundPeerdelivered, not the transport's name-only AWDL placeholder — real MC hands back the identicalMCPeerIDboth 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).reinviteAfterDisconnectOverQUIC(retained-endpoint re-invite) still passes —leaveSession()semantics untouched.🤖 Generated with Claude Code