Repository navigation
Directed presence channel: server can wake the Mac (nudge push) - #9012
Conversation
The Mac publishes to presence and the broker but receives nothing, so a
server-side change to its iroh binding (revocation, re-key replacement)
only reached it on the next scheduled broker round trip, up to ~45
minutes later. The phone already holds a presence WebSocket; this adds
the Mac-side equivalent as a quiet directed channel.
Presence worker: `?deviceScope=<deviceId>` on the subscribe route turns
the stream into a WebSocket-only nudge channel — no snapshot, no team
presence chatter, no sync — gated by the same first-heartbeat owner pin
as heartbeats (subscribing never writes the pin). A new owner-only
`POST /v1/presence/nudge {deviceId, tag?, kind}` delivers a
`{type: "nudge"}` frame to that device's scoped sockets. Nudges are
never sent to normal subscribers, mirroring how sync frames are gated
on `sync.hello`, so legacy presence decoders that throw on unknown
event types never see one. Kinds are a server-side allowlist
(`iroh-binding-changed`); the frame carries no route or binding data.
Mac app: `PresenceNudgeSubscriber` mirrors `PresenceHeartbeatClient`'s
gating and holds the directed stream with 1s→60s reconnect backoff. A
nudge for this device (and build tag, when given) calls the new
`CmxIrohHostRuntime.requestRegistrationRefresh()` — one immediate
registration/policy round through the existing coalesced refresh path —
plus `retryIfNeeded()` for absent runtimes. Against a pre-nudge worker
the same endpoint serves snapshot/presence frames, which the subscriber
ignores, so old servers degrade to a no-op.
The broker-side hook that fires the nudge on revocation/replacement is
deliberately a follow-up: those mutation paths are being rewritten by
the in-flight binding re-key work (PR 8883), and the endpoint is
independently drivable until then.
Worker: bun test 183 pass (9 new), typecheck clean. Package: 37
CmxIrohHostRuntime tests pass (2 new for requestRegistrationRefresh).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds authenticated, device-scoped presence nudges through the worker and mobile WebSocket subscriber. Accepted nudges trigger mobile Iroh registration refreshes, with lifecycle wiring, runtime APIs, validation, documentation, project integration, and tests. ChangesPresence nudge registration refresh
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant PresenceWorker
participant PresenceNudgeSubscriber
participant MobileHostIrohRuntime
participant CmxIrohHostRuntime
Caller->>PresenceWorker: POST /v1/presence/nudge
PresenceWorker-->>PresenceNudgeSubscriber: deliver device-scoped nudge
PresenceNudgeSubscriber->>MobileHostIrohRuntime: refreshRegistrationFromServerSignal()
MobileHostIrohRuntime->>CmxIrohHostRuntime: requestRegistrationRefresh()
CmxIrohHostRuntime-->>MobileHostIrohRuntime: settle registration refresh
Possibly related PRs
Suggested reviewers: Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (2 errors)
✅ Passed checks (23 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@workers/presence/src/do.ts`:
- Around line 400-439: Update the delivery loop in nudge to require both the
matching device scope and wsUserId(ws) matching the current pinned owner before
sending the event. Preserve the existing expiry and delivery-count behavior,
ensuring sockets subscribed while the device was unpinned do not receive nudges
after another user claims it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ab9df5d7-85f5-416b-9aec-6dc6ff471e76
📒 Files selected for processing (12)
Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PublicAPI.swiftPackages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swiftSources/AppDelegate.swiftSources/Cloud/PresenceNudgeSubscriber.swiftSources/Mobile/MobileHostIrohRuntime.swiftcmux.xcodeproj/project.pbxprojworkers/presence/README.mdworkers/presence/src/core.tsworkers/presence/src/do.tsworkers/presence/src/index.tsworkers/presence/src/validate.tsworkers/presence/test/nudge.test.ts
…cycle, delivery ownership Five review findings, all confirmed against the code: - The subscribe URL kept the https scheme; URLSessionWebSocketTask needs wss, so the channel never connected. Convert https/http to wss/ws, same as the iOS PresenceClient. - A nudge-triggered refresh that discovers the binding was replaced (different binding id) fails closed into the terminal .failed phase and nothing rebuilt it. requestRegistrationRefresh now awaits the refresh round settling, and the composition root reads the post-refresh snapshot and rebuilds through reconcile with restartActiveRuntime so a fresh activation re-registers under the new server state. New package test pins the fail-closed contract for a replaced binding id. - evaluate() only toggled on/off, so a team or service-URL change rode the old socket to the 15-minute deadline. The loop is now keyed by a team+URL scope and restarts when the scope changes. - URLSessionWebSocketTask.receive() ignores Swift task cancellation, so disabling presence left the socket suspended in receive until expiry. The receive loop runs under withTaskCancellationHandler that cancels the socket, and frames received after cancellation are dropped. - The DO delivered nudges by deviceScope alone; a subscriber who lost the first-heartbeat pin race could still receive owner-only frames. Delivery now also requires the socket's verified user to equal the device's current pinned owner. Worker: 183 bun tests pass, typecheck clean. Package: 38 CmxIrohHostRuntime tests pass (replaced-binding case new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uiet-close backoff Four fixes from the second structured review pass: - requestRegistrationRefresh() now awaits across the coalesced replay round, not just the in-flight one, so a caller that rebuilds on `.failed` observes the state AFTER the replay the pending bit scheduled. - PresenceNudgeSubscriber's scope key includes the authenticated user id and requires isAuthenticated, so two solo accounts (nil resolvedTeamID) can never share a directed stream scope, and auth identity changes restart the loop via an @observable tracking re-arm. - MobileHostIrohRuntime.refreshRegistrationFromServerSignal() is single-flight with a pending bit: a burst of nudge frames coalesces into one follow-up refresh instead of fanning out one main-actor waiter per frame. - subscribeOnce() treats a normal/going-away close as healthy service: a directed stream is silent between nudges, so the quiet 15-minute renewal close must reset backoff instead of doubling it toward 60s gaps that could swallow a one-shot nudge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
Sources/Cloud/PresenceNudgeSubscriber.swift (3)
24-37: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftMake the subscriber a composition-root-owned dependency.
sharedadds ambient mutable runtime state underSources/. Construct and retain this subscriber in the application lifecycle owner, then inject itsAuthCoordinator; do not use a process-wide singleton for the socket loop.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Cloud/PresenceNudgeSubscriber.swift` around lines 24 - 37, Remove the process-wide static shared instance from PresenceNudgeSubscriber and make the class constructible by the application lifecycle owner. Have that owner retain a single subscriber instance and inject its AuthCoordinator during initialization, updating call sites to use the injected instance so the socket loop is composition-root-owned rather than ambient global state.Source: Coding guidelines
112-236: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy liftKeep WebSocket receive and frame decoding off the main actor.
@MainActorclass methods are isolated to the main actor, sotask.receive()andhandleFrame’sJSONSerializationrun on the UI thread for every remote frame. Route the receive/decode work to a non-UI shared worker actor or explicit concurrent boundary, then forward only a validated nudge value snapshot back to@MainActorfor the registration/logging action.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Cloud/PresenceNudgeSubscriber.swift` around lines 112 - 236, The startLoop task and subscribeOnce currently perform WebSocket receiving and JSON frame decoding on the main actor. Move receive and decoding out of `@MainActor` into a non-UI worker actor or explicit concurrent boundary, and have that boundary return only a validated nudge value snapshot; keep logging and MobileHostIrohRuntime.shared.refreshRegistrationFromServerSignal() on the main actor, replacing direct handleFrame calls accordingly.Source: Coding guidelines
112-130: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftRemove the sleeping reconnect retry loop.
startLoop()coordinates reconnect backoff withContinuousClock().sleep(for:), creating a production timer-driven retry path. Move reconnect/refresh backoff to the transport/lifecycle owner or make this wait on an event/signaler rather than sleeping between WebSocket attempts.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Cloud/PresenceNudgeSubscriber.swift` around lines 112 - 130, Remove the timer-driven reconnect loop from startLoop(), including ContinuousClock, failureDelay, and clock.sleep(for:) backoff handling. Delegate reconnect/refresh scheduling to the transport or lifecycle owner, or wait on its event/signaler instead, while preserving cancellation and the existing subscribeOnce() coordination.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@Sources/Cloud/PresenceNudgeSubscriber.swift`:
- Around line 24-37: Remove the process-wide static shared instance from
PresenceNudgeSubscriber and make the class constructible by the application
lifecycle owner. Have that owner retain a single subscriber instance and inject
its AuthCoordinator during initialization, updating call sites to use the
injected instance so the socket loop is composition-root-owned rather than
ambient global state.
- Around line 112-236: The startLoop task and subscribeOnce currently perform
WebSocket receiving and JSON frame decoding on the main actor. Move receive and
decoding out of `@MainActor` into a non-UI worker actor or explicit concurrent
boundary, and have that boundary return only a validated nudge value snapshot;
keep logging and
MobileHostIrohRuntime.shared.refreshRegistrationFromServerSignal() on the main
actor, replacing direct handleFrame calls accordingly.
- Around line 112-130: Remove the timer-driven reconnect loop from startLoop(),
including ContinuousClock, failureDelay, and clock.sleep(for:) backoff handling.
Delegate reconnect/refresh scheduling to the transport or lifecycle owner, or
wait on its event/signaler instead, while preserving cancellation and the
existing subscribeOnce() coordination.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 8d19c211-2203-4866-8364-c7d2d0ba4b7c
📒 Files selected for processing (3)
Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PublicAPI.swiftSources/Cloud/PresenceNudgeSubscriber.swiftSources/Mobile/MobileHostIrohRuntime.swift
Review round 3 flagged that the security-sensitive delivery filter (owner re-check per frame, directed-socket routing) had no behavior coverage. Following the suite's no-Workers-runtime pattern (checkDeviceOwner), the per-socket decision moves into a pure shouldDeliverNudge in core.ts, the DO delivery loop calls it, and tests cover: normal presence subscribers never receive nudges, wrong-device scopes and expired sockets are excluded, a subscriber who lost the first-heartbeat pin race is excluded at delivery despite an accepted subscription, legacy sockets without a verified user id never match, and a mixed subscriber set delivers to exactly the pinned owner's directed socket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… main Review round 4 P1: against a pre-nudge worker, ?deviceScope= is ignored and the directed socket degrades to a full presence subscription — a team snapshot (megabytes at the service's caps) followed by seen events, each JSON-parsed on the main actor before being discarded. The receive loop now classifies each frame before parsing: anything over a 2 KiB bound (a real nudge is ~200 bytes) is foreign in O(1), so a snapshot is never parsed. The first foreign frame proves the endpoint is legacy — a nudge-aware worker sends only nudge frames on a directed stream — so the subscriber closes immediately and re-probes every 15 minutes instead of pumping team traffic. A legacy worker has no nudges to deliver, so the slow probe loses nothing; once the worker upgrades, the next probe holds a normal directed stream. Also documents the deliberately accepted first-writer pin residual on the nudge authorization path (do.ts, README): the presence worker keeps no synchronous registry dependency by design, and a squatted pin only suppresses the acceleration — the Mac falls back to its pre-nudge renewal cadence, never to a correctness failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/Cloud/PresenceNudgeSubscriber.swift (1)
26-38: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftReplace the ambient subscription singleton with an injected lifecycle owner.
This global owns mutable auth, observer, and socket-task state. Construct and retain it at the app composition root; have callbacks weakly capture that instance instead of routing through
shared.As per coding guidelines, “Avoid new ambient global runtime state … runtime singletons. Prefer constructable injectable owners.” Based on learnings, the singleton exception is for a sole app-wide persisted resource, which this auth-bound stream is not.
Also applies to: 51-54, 71-75
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Cloud/PresenceNudgeSubscriber.swift` around lines 26 - 38, Replace the PresenceNudgeSubscriber.shared singleton with an explicitly constructed, retained instance owned by the app composition root. Remove the static shared accessor, expose construction as needed, and update callbacks and references around PresenceNudgeSubscriber to weakly capture and use the injected instance while preserving its auth, observer, and loop lifecycle state.Sources: Coding guidelines, Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/Cloud/PresenceNudgeSubscriber.swift`:
- Around line 221-234: Update the message switch in the nudge frame handling
path to return .legacyEndpoint immediately for .data messages, before
constructing or UTF-8 decoding text. Preserve string processing through
classifyAndHandleFrame, while keeping unknown message cases treated as legacy.
---
Outside diff comments:
In `@Sources/Cloud/PresenceNudgeSubscriber.swift`:
- Around line 26-38: Replace the PresenceNudgeSubscriber.shared singleton with
an explicitly constructed, retained instance owned by the app composition root.
Remove the static shared accessor, expose construction as needed, and update
callbacks and references around PresenceNudgeSubscriber to weakly capture and
use the injected instance while preserving its auth, observer, and loop
lifecycle state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 86ecb2bc-f9c3-4f48-a94d-91f79d2ca4f5
📒 Files selected for processing (3)
Sources/Cloud/PresenceNudgeSubscriber.swiftworkers/presence/README.mdworkers/presence/src/do.ts
| let text: String? | ||
| switch message { | ||
| case let .string(string): text = string | ||
| case let .data(data): text = String(data: data, encoding: .utf8) | ||
| @unknown default: text = nil | ||
| } | ||
| // A nudge-aware worker sends nothing but small text nudge | ||
| // frames on a directed stream, so the first frame that is | ||
| // anything else (a snapshot, presence chatter, binary) proves | ||
| // the endpoint is legacy: stop pumping it instead of paying a | ||
| // main-actor decode for team-sized traffic. | ||
| guard let text, classifyAndHandleFrame(text) == .nudge else { | ||
| return .legacyEndpoint | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Reject binary frames before decoding them on MainActor.
Line 224 decodes the entire binary payload before the 2 KB guard at Line 259. A large binary legacy frame can still allocate and UTF-8 decode on the main actor. Binary is already treated as a foreign/legacy protocol frame, so return .legacyEndpoint directly.
Proposed fix
- let text: String?
+ let text: String
switch message {
case let .string(string): text = string
- case let .data(data): text = String(data: data, encoding: .utf8)
- `@unknown` default: text = nil
+ case .data:
+ return .legacyEndpoint
+ `@unknown` default:
+ return .legacyEndpoint
}
- guard let text, classifyAndHandleFrame(text) == .nudge else {
+ guard classifyAndHandleFrame(text) == .nudge else {
return .legacyEndpoint
}As per coding guidelines, parsing-heavy work invoked from UI isolation needs an explicit off-main boundary; this avoids the unbounded decode entirely.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let text: String? | |
| switch message { | |
| case let .string(string): text = string | |
| case let .data(data): text = String(data: data, encoding: .utf8) | |
| @unknown default: text = nil | |
| } | |
| // A nudge-aware worker sends nothing but small text nudge | |
| // frames on a directed stream, so the first frame that is | |
| // anything else (a snapshot, presence chatter, binary) proves | |
| // the endpoint is legacy: stop pumping it instead of paying a | |
| // main-actor decode for team-sized traffic. | |
| guard let text, classifyAndHandleFrame(text) == .nudge else { | |
| return .legacyEndpoint | |
| } | |
| let text: String | |
| switch message { | |
| case let .string(string): text = string | |
| case .data: | |
| return .legacyEndpoint | |
| `@unknown` default: | |
| return .legacyEndpoint | |
| } | |
| // A nudge-aware worker sends nothing but small text nudge | |
| // frames on a directed stream, so the first frame that is | |
| // anything else (a snapshot, presence chatter, binary) proves | |
| // the endpoint is legacy: stop pumping it instead of paying a | |
| // main-actor decode for team-sized traffic. | |
| guard classifyAndHandleFrame(text) == .nudge else { | |
| return .legacyEndpoint | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/Cloud/PresenceNudgeSubscriber.swift` around lines 221 - 234, Update
the message switch in the nudge frame handling path to return .legacyEndpoint
immediately for .data messages, before constructing or UTF-8 decoding text.
Preserve string processing through classifyAndHandleFrame, while keeping unknown
message cases treated as legacy.
Source: Coding guidelines
…lose Review round 5: - Directed (device-scoped) sockets no longer draw from the shared 64-subscriber presence pool. Every enabled Mac instance holds one, so a fleet of Macs or tagged dev builds could deterministically 429 the phones' presence streams. Admission is now a pure, tested decision (checkSubscriberAdmission): directed sockets get their own bounded pool of 256 and each pool only rejects its own kind. - An EMPTY cleanly-closed stream counts as served only after living 60 seconds. The close code alone let an accept-then-close loop (persistent drain, misbehaving proxy) pin every Mac at one WebSocket handshake per second forever; the healthy quiet close arrives at the service's 15-minute deadline, far above the threshold, so normal renewals still resubscribe promptly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Review round 6: - Directed subscribe admits unpinned devices by design (a Mac subscribes before its first heartbeat), which let one member park sockets on arbitrary fresh UUIDs until the 256-socket team pool 429'd legitimate owners. Admission now also enforces a per-user slice (32), so one member can never reach the team ceiling; the pure decision and its tests cover both pools and the slice. - The Mac's 2 KiB nudge bound moved from post-receive classification to URLSessionWebSocketTask.maximumMessageSize, so a legacy worker's team snapshot fails the receive (EMSGSIZE) before it is buffered instead of after megabytes land in memory. That failure classifies as .legacyEndpoint, converging with the parsed-foreign-frame path on the 15-minute reprobe. The in-classifier length check stays as a second layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…scribe # Conflicts: # Sources/Mobile/MobileHostIrohRuntime.swift
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Comment-only. Review round 7 flagged that a superseded host answering a replacement nudge re-registers (mutating the newest-wins slot) before it detects the changed binding id. That ordering is the pre-existing renewal path; the nudge deliberately reuses it unchanged, and the displaced-instance disposition (stand down without re-taking the slot) belongs to the nudge-emission hook that fires from the authoritative broker mutation — deferred with it to the follow-up PR behind #8883. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #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 newPOST /v1/presence/nudge {deviceId, tag?, kind}(owner-only,kindfrom a server-side allowlist, currentlyiroh-binding-changed) delivers a{type: "nudge"}frame to that device's scoped sockets and reportsdelivered(0 is success: an offline Mac catches up via its normal round trips). Nudge frames go only to device-scoped sockets, mirroring thesync.hellogating, so legacy presence decoders that throw on unknown event types never see one. The frame carries no route or binding data.Mac app.
PresenceNudgeSubscriber(mirrorsPresenceHeartbeatClientgating: 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 newCmxIrohHostRuntime.requestRegistrationRefresh(), one immediate registration/policy round through the existing coalesced refresh path, plusretryIfNeeded()so an absent runtime reactivates. Composes with #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 (
maximumMessageSize2 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 indo.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 #8883 merges, because those
trustBroker.ts/repository.tsmutation 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
bun test195 pass (21 new intest/nudge.test.ts: parsing, owner-scoped delivery, split admission pools),tsgotypecheck clean.CmxIrohHostRuntime*tests pass, 3 new inCmxIrohHostRuntimeRequestedRefreshTests(immediate broker round when active; no-op when inactive; a replaced binding settles.failedby the time the await returns).macsub.No user-facing strings were added (log lines only), so no localization changes are needed.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith 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
GET /v1/presence/subscribe?deviceScope=<deviceId>is a WebSocket-only nudge channel;POST /v1/presence/nudge {deviceId, tag?, kind}(owner-only; allowlistedkind: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.PresenceNudgeSubscriberholds 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 callsCmxIrohHostRuntime.requestRegistrationRefresh()(single‑flight with replay; accelerates the existing renewal round only) and rebuilds the runtime on a replaced binding.Bug Fixes
retryIfNeeded().maximumMessageSizecauses 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.Written for commit 0c093b6. Summary will update on new commits.
Summary by CodeRabbit
POST /v1/presence/nudgeendpoint and push-only device-scoped WebSocket subscriptions.