Directed presence channel: server can wake the Mac (nudge push)

Merged
AZ
3mo ago
+1091-9

Problem

The Mac publishes to presence (15s heartbeats) and the iroh trust broker (signed registration) but receives nothing. A server-side change to its binding — revocation, or the newest-wins replacement coming in https://github.com/manaflow-ai/cmux/pull/8883 — only reaches it on the next scheduled broker round trip, up to ~45 minutes later. The App Store review incident sat in exactly that blind spot. Phones already hold a presence WebSocket; this gives the Mac the equivalent, as a quiet directed channel.

Mechanism

Presence worker. GET /v1/presence/subscribe?deviceScope=<deviceId> turns the stream into a directed nudge channel: WebSocket-only, no snapshot, no team presence chatter, no sync. Authorization reuses the first-heartbeat device-owner pin (an unpinned device may subscribe, since the Mac connects at startup possibly before its first heartbeat, but subscribing never writes the pin). A new POST /v1/presence/nudge {deviceId, tag?, kind} (owner-only, kind from a server-side allowlist, currently iroh-binding-changed) delivers a {type: "nudge"} frame to that device's scoped sockets and reports delivered (0 is success: an offline Mac catches up via its normal round trips). Nudge frames go only to device-scoped sockets, mirroring the sync.hello gating, so legacy presence decoders that throw on unknown event types never see one. The frame carries no route or binding data.

Mac app. PresenceNudgeSubscriber (mirrors PresenceHeartbeatClient gating: same enable flag, service URL resolution, auth) holds the directed stream with 1s→60s reconnect backoff, reset when a stream delivers; the service bounds streams to token expiry, so clean close plus resubscribe is steady state. A nudge for this device and build tag calls the new CmxIrohHostRuntime.requestRegistrationRefresh(), one immediate registration/policy round through the existing coalesced refresh path, plus retryIfNeeded() so an absent runtime reactivates. Composes with https://github.com/manaflow-ai/cmux/pull/8930: once both merge, a nudge also instantly rebuilds a terminally failed runtime.

Compatibility. Against a pre-nudge worker the same endpoint serves snapshot/presence frames instead. The subscriber bounds messages at the transport (maximumMessageSize 2 KiB, so a team snapshot fails the receive before it is buffered) and treats the first non-nudge frame as proof of a legacy endpoint: it closes and re-probes every 15 minutes, costing one small handshake per probe instead of pumping team traffic through the main actor. Old clients are untouched (no new frame ever reaches a non-scoped socket).

Principled or hacky: principled — it reuses the existing presence transport, DO, auth, and ownership pin rather than adding a service, and the channel is a latency optimization layered over pull-is-truth, so its failure modes cost freshness, never correctness. Residual risk: directed sockets hold their own admission pool (256 per team, 32 per user, pure-tested checkSubscriberAdmission), so a Mac fleet can never 429 the phones' presence streams and one member cannot park sockets on arbitrary UUIDs until co-members' channels starve. The first-writer owner pin's known residual extends to nudges deliberately (documented in do.ts/README): a squatted pin suppresses only the acceleration, and the Mac falls back to its pre-nudge renewal cadence.

Deliberately deferred (PR 2)

The broker hook that fires the nudge on revocation/replacement lands after https://github.com/manaflow-ai/cmux/pull/8883 merges, because those trustBroker.ts/repository.ts mutation paths are being rewritten there and the replacement event only exists with its newest-wins semantics. Until then the endpoint is independently drivable (authenticated curl or a future phone-side "reconnect my Mac" action). That hook also owns the displaced-instance disposition: a replacement nudge should carry authoritative supersession so an old process can stop instead of re-registering through the mutating renewal path and reclaiming the newest-wins slot. Until emission exists a nudge only accelerates the renewal round that main already runs on its 45-minute timer, so this PR changes no displacement semantics.

Verification

  • Worker: bun test 195 pass (21 new in test/nudge.test.ts: parsing, owner-scoped delivery, split admission pools), tsgo typecheck clean.
  • Package: 41 CmxIrohHostRuntime* tests pass, 3 new in CmxIrohHostRuntimeRequestedRefreshTests (immediate broker round when active; no-op when inactive; a replaced binding settles .failed by the time the await returns).
  • Tagged fleet build macsub.
  • The worker deploy (dev, then prod) happens at merge time; until the dev worker is deployed, the tagged Mac build connects to the old worker and idles harmlessly, which is the compatibility path above.

No user-facing strings were added (log lines only), so no localization changes are needed.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds a device-scoped presence channel so the server can nudge a Mac and trigger an immediate iroh registration refresh after binding changes. Nudges only change when the renewal runs, not what it does.

  • New Features

    • Worker: GET /v1/presence/subscribe?deviceScope=<deviceId> is a WebSocket-only nudge channel; POST /v1/presence/nudge {deviceId, tag?, kind} (owner-only; allowlisted kind: iroh-binding-changed) sends {type:"nudge"} only to device‑scoped sockets and returns a delivered count. Separate caps: 256 directed sockets, 64 presence subscribers, and 32 directed per user.
    • Mac: PresenceNudgeSubscriber holds the directed stream with 1s→60s backoff, converts https/http→wss/ws, enforces a 2 KiB transport bound, treats quiet deadline closes as healthy, restarts on auth/team/service‑URL changes, and shuts down on disable. On a matching nudge it calls CmxIrohHostRuntime.requestRegistrationRefresh() (single‑flight with replay; accelerates the existing renewal round only) and rebuilds the runtime on a replaced binding.
  • Bug Fixes

    • Await across the coalesced refresh replay; burst nudges coalesce into one follow‑up round; absent runtimes trigger retryIfNeeded().
    • Revalidate delivery ownership per frame and route only to device‑scoped sockets; exclude wrong‑device, legacy (no user), expired, or lost‑pin‑race sockets.
    • Detect legacy endpoints without parsing: 2 KiB maximumMessageSize causes EMSGSIZE on snapshots; first foreign/oversized frame marks legacy and re‑probes every 15 minutes. Clean, empty closes count as served only after ≥60 s to avoid accept‑then‑close loops.

Summary by CodeRabbit

  • New Features
    • Added directed, device-scoped presence nudges that trigger single-flight, coalesced registration refreshes for faster recovery after server binding changes.
    • Introduced an authenticated POST /v1/presence/nudge endpoint and push-only device-scoped WebSocket subscriptions.
    • Automatically manages the nudge subscriber during app startup and termination.
    • Added a public async method to request an immediate registration refresh when the runtime is active.
  • Bug Fixes
    • Improved resilience when server-side bindings change during a refresh cycle.
  • Documentation
    • Updated presence worker docs with directed nudge and validation semantics.
  • Tests
    • Added coverage for nudge parsing/delivery eligibility and requested-refresh behavior.