Skip to content

cnx-ios-api: per-user signaling hub (users on one team never see each other's devices) - #22260

Closed
azooz2003-bit wants to merge 13 commits into
feat-cnx-iosfrom
feat-cnx-ios-userhub
Closed

azooz2003-bit wants to merge 13 commits into
feat-cnx-iosfrom
feat-cnx-ios-userhub

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The cnx-ios-api signaling hub (TeamHub Durable Object) was one per team, so two users on the same team shared presence, hello Mac lists and GET /v1/teams/:teamId/macs. It is now one per user. Different users never see, list, signal or reach each other's devices, even on a shared team.

  • Key: idFromName("user:" + owner key). The owner key is the verified Stack token's sub (<projectId>:<sub> outside the primary project), never a client value. A hub binds its owner on first use and refuses calls for any other owner, so a routing bug cannot mix two users.
  • What the team still scopes (a view, not a trust boundary): the path's teamId must be a team the caller belongs to, and the device must be registered there (else 403 wrong_team). hello and presence list only the caller's Macs in the socket's team, as before. Signal admission is by user only, so a phone may dial any of its own Macs in any team.
  • REST: GET /v1/teams/:teamId/macs lists only the caller's own Macs; ownerUserId is always the caller. Every other route was already owner-scoped and now reads presence from the caller's hub.
  • Hub jobs kept: signaling (introductions), presence, wg-endpoints, and the retired-relay refusal (relay_retired). Push (notify) never went through the hub and stays owner-scoped.
  • PROTOCOL.md documents this as API 0.10.0.

Migration

  • The URL, frames and close codes are unchanged, so old phone and Mac builds (including the INTERNAL TestFlight build) need no update.
  • Hibernated WebSockets survive a deploy. I observed this live: a running cnx-hostd stayed connected through the deploy. So a Mac could stay parked in its old team hub while its phone lands in the user hub and sees it offline. Fix: the first time an isolate serves a team (after the membership check), the Worker calls retireTeamHub on the old hub. That closes its sockets with 1012, so clients reconnect into their user hub, and deletes its rows, which mixed the team's users. A user hub is never retired: a hub binds only an owner key whose user:<key> name is the object's own id. Remove this once no team hub holds sockets.
  • Retirement must survive a deploy: a Durable Object keeps the previous class until it is evicted. So failed retires are retried; a hub still on the previous class has its team's devices moved out through that class's own forgetMac (4001 moved); and a cron every minute sweeps every team's hub. See the incident comment below.
  • A new user hub is seeded once from the database with the user's Macs, so hello still lists a registered Mac that has not reconnected yet. Devices deleted in the hub are never seeded back.
  • A Mac that re-registers into another team is still closed with 4001 moved. A phone's socket stays open, as with team hubs, so its links keep signaling.

Verification

Worker tests (vitest in workerd, run on mini-cmux11s, not the laptop): 60 passed.

  • New test/isolation.test.ts covers two users on one team across REST lists, bootstrap, /me/*, takeover, delete, push, notify, hello, presence, signals of every kind including wg-endpoints, session reuse in both directions, a spoofed from, opening a socket as a teammate's device, and the owner binding.
  • It also covers cross-team dials for one user, Mac and phone team moves, DB seeding, account purge, and retiring a parked team hub.
  • On the base code, 11 of these tests fail (red commit ac971780df0).

Live, against a separate Worker cnx-ios-api-userhub-dev (dev env, shared dev DB through Hyperdrive, fresh dev Stack users cmux-next-ios-userhub-{c,d}@example.com). Nothing was deployed to the shared dev or prod Worker.

  • Smoke (scripts/smoke.ts, user C, personal space): 31/32 pass. The failure is ice turn: this Worker has no current TURN key (see below).
  • Cross-user (user D against user C's online cnx-hostd): 18/18 pass. D's /teams/:id/macs, /me/macs, /me/devices and bootstrap show nothing of C. Takeover, delete, notify, /teams/<C>/macs and /teams/<C>/hub are refused. Sockets opened as C's Mac are refused. D's webrtc-offer, wg-init, wg-endpoints and bye to C's online Mac all get not_owner. D's phone still reaches D's own Mac. D's sockets heard nothing of C.
  • Migration rehearsal:
    • Base code deployed, Oct 14 cnx-hostd connected, old cnx-dial WebRTC direct in 298 ms.
    • This branch deployed: the old Mac stayed parked in its team hub, and /me/macs showed it offline.
    • The phone's first /me/macs retired the hub. The Mac logged signaling closed by server: 1012 moved to the user hub, re-registered and reconnected in 0.9 s, and was online 3 s later.
    • Old and new cnx-dial then connected to the old Mac over WebRTC: direct, 400-1500 ms.
  • Pairing, branch builds:
    • mini to mini, WebRTC: direct, 472 ms connect.
    • GCP (rbx) to mini, WireGuard: direct, 682-888 ms connect, 57-60 ms RTT.
    • GCP to mini, WebRTC: direct, 1131 ms.
  • Caveat: this Worker is STUN-only, so about 1 in 4 dials timed out at ICE or the handshake after signaling completed (the Mac answered). Controls on the shared team-hub Worker show the same transports need TURN between these hosts (WireGuard with TURN: path turn, 462 ms). I could not set TURN on this Worker: the local copy of the TURN key is rotated (403), and op is not signed in.

Not live-tested: two different users on the same Stack team. No two test users share a team. Stack refuses client-side membership adds (INSUFFICIENT_ACCESS_TYPE), no Stack server key is available, and an invitation needs an email code. The same-team case is covered by the Worker tests above, and the per-user hub makes the team irrelevant to isolation.

No Swift or Rust code changed, so no Swift or Rust tests ran. hostd and the iOS app keep the same URL, and the app already filters other users' Macs.

Review

A correctness-first review subagent found no isolation leak and no blocker. Its three should-fix findings in the migration and seeding paths are fixed in 0f6b3e64007; details are in the PR comment.

Changelog

none

🤖 Generated with Claude Code

azooz2003-bit and others added 5 commits October 19, 2026 17:28
Two users on one team must not see, list, signal or reach each other's
devices through any REST endpoint or hub message. Fails on the per-team
TeamHub: presence and team Mac lists cross users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TeamHub Durable Object is now one per user (idFromName("user:" +
owner key from the verified Stack token)). Signaling, presence,
introductions and wg-endpoints only ever span one user's own devices.
GET /v1/teams/:teamId/macs lists only the caller's own Macs. The hub binds
its owner on first use and refuses any other. The signal URL is unchanged,
so old phone and Mac builds reconnect to their user hub after deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As with team hubs, only a Mac that re-registers into another team is
closed with 4001; a phone's link keeps signaling (admission is by user).
Document hub_unavailable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hibernated sockets survive a deploy, so a Mac could stay parked in its
old team hub while its phone reconnects into the user hub and sees it
offline. The first time an isolate serves a team (after the membership
check), its old team hub closes its sockets with 1012 and deletes its
rows, which mixed the team's users. A user hub is never retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…are retired

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blacksmith-sh

blacksmith-sh Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Found 14 test failures on Blacksmith runners:

Failures

Test View Logs
cnx-ios-testflight.yml job testflight never runs scripts/
select-ci-xcode.sh, so it builds with the image's default Xcode
View Logs
GitHub-hosted runner label: .github/workflows/
pr-red-checks-comment.yml:16: runs-on: ubuntu-24.04
View Logs
GitHub-hosted runner label: .github/workflows/
pr-red-checks-comment.yml:16: runs-on: ubuntu-24.04
View Logs
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='cmux-scheme-compile')/
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='cmux-scheme-compile')
View Logs
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='daemon-test')/
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='daemon-test')
View Logs
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='generated-files')/
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='generated-files')
View Logs
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='release-compile')/
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='release-compile')
View Logs
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='swift-test')/
test_every_mac_job_reads_the_placement (main.CmuxNextWiring.test_every_mac_job_read
s_the_placement) (job='swift-test')
View Logs
test_every_shadow_job_is_compare_only (main.ShadowJobTests.test_every_shadow_job_is
_compare_only) (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-runtime-x86_64')
/test_every_shadow_job_is_compare_only (main.ShadowJobTests.test_every_shadow_job_i
s_compare_only) (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-runtime-x86_64'
)
View Logs
test_every_shadow_job_is_compare_only (main.ShadowJobTests.test_every_shadow_job_is
_compare_only) (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-shadow')/
test_every_shadow_job_is_compare_only (main.ShadowJobTests.test_every_shadow_job_is
_compare_only) (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-shadow')
View Logs

...and 4 more test failures. View all on Blacksmith

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

- retireTeamHub recreates its tables after deleteAll (a second call on the
  same live object threw), skips sockets it already closed, and logs a
  failure instead of retrying on every request.
- A hub binds only an owner key that names the object itself
  (idFromName(user:<key>) equals its id). An older Worker can no longer
  bind a team hub during the deploy overlap and so exempt it from
  retirement.
- Seeding runs once per hub and skips devices deleted there, so a
  database read that raced a deletion cannot bring a Mac back.
- Tests for each, and for a device id re-registered by another user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Probably yours: Fast static checks failed: a static check failed, FAILED localization (0.82s).

CI failed on 05427a94a7 (run 39372390203 attempt 1): 3 code, 5 unknown.

Job Verdict Why
Fast static checks code a static check failed
CI fast guards code a guard step failed
guards / workflow-guard-history unknown no known signature; failed step: Validate SwiftPM lockfile policy
guards / workflow-guard-tests / release-tooling unknown no known signature; failed step: Validate retried Ghostty Zig package fetches
guards / workflow-guard-tests / app-host-cache unknown no known signature; failed step: Validate iOS video recording failures
guards / workflow-guard-tests / quality-determinism code a Python test failed
guards / workflow-guard-tests / preflight unknown no known signature; failed step: Validate macOS runner guards
guards / workflow-guard-tests / release-ios unknown no known signature; failed step: Validate iOS simulator build-once contract
Matched log lines
Fast static checks: FAILED localization (0.82s)
CI fast guards: FAIL    0.1s  workflow-guard-tests / ci: Validate macOS jobs select a pinned Xcode
guards / workflow-guard-tests / quality-determinism: FAIL: test_scans_only_test_sources (__main__.CommandLine.test_scans_only_test_sources)

Not re-run automatically: Fast static checks, CI fast guards, guards / workflow-guard-history, guards / workflow-guard-tests / release-tooling, guards / workflow-guard-tests / app-host-cache are not machine failures.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

CI fast guards failed on 05427a94a7 (https://github.com/manaflow-ai/cmux/actions/runs/39372390203). It does not block the merge; a red guard merged into main breaks it for every open PR.

Validate macOS jobs select a pinned Xcode

Test: python3 tests/test_ci_macos_xcode_selection.py

cnx-ios-testflight.yml job testflight never runs scripts/select-ci-xcode.sh, so it builds with the image's default Xcode

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate macOS jobs select a pinned Xcode'

Check that push and pull_request path filters agree

Test: python3 tests/test_ci_workflow_path_filter_parity.py

Traceback (most recent call last):
  File "/home/runner/_work/cmux/cmux/tests/test_ci_workflow_path_filter_parity.py", line 245, in <module>
    test_push_and_pull_request_filter_the_same_files()
  File "/home/runner/_work/cmux/cmux/tests/test_ci_workflow_path_filter_parity.py", line 156, in test_push_and_pull_request_filter_the_same_files
    assert not drifted, (
           ^^^^^^^^^^^
AssertionError: Every workflow that filters both events must filter them identically, or declare the difference in EXEMPTIONS or PUSH_ONLY_PATTERNS with a reason.
backend.yml: push and pull_request filter different files
  paths: guarded on push, not on pull requests: scripts/cmux-next/release/prod-promote.ts, scripts/cmux-next/release/production-approvals.json

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Check that push and pull_request path filters agree'

Validate local hook trust boundary

Test: python3 tests/test_preflight_trust.py

  • PreflightTrustTests.test_annotated_tag_push_does_not_execute_candidate_code
subprocess.CalledProcessError: Command '['bash', 'scripts/install-git-hooks.sh']' returned non-zero exit status 1.
  • PreflightTrustTests.test_hook_installation_has_no_candidate_code_runner
subprocess.CalledProcessError: Command '['bash', 'scripts/install-git-hooks.sh']' returned non-zero exit status 1.
  • PreflightTrustTests.test_push_does_not_execute_candidate_check_through_unchanged_wrapper
subprocess.CalledProcessError: Command '['bash', 'scripts/install-git-hooks.sh']' returned non-zero exit status 1.
  • PreflightTrustTests.test_push_does_not_execute_candidate_runner
subprocess.CalledProcessError: Command '['bash', 'scripts/install-git-hooks.sh']' returned non-zero exit status 1.

... and 1 more failing tests in the run log.
Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate local hook trust boundary'

Run canonical CMUX CI guard profile

GitHub-hosted runner label: .github/workflows/pr-red-checks-comment.yml:16:    runs-on: ubuntu-24.04

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Run canonical CMUX CI guard profile'

Validate package test selection

Test: python3 tests/test_ci_select_package_tests.py

    result, count = run_package_step("CmuxUpdater", [(startup, 1), (passed, 0)])
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/runner/_work/cmux/cmux/tests/test_ci_select_package_tests.py", line 102, in run_package_step
    count = int((root / "calls").read_text())
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/pathlib.py", line 1029, in read_text
    with self.open(mode='r', encoding=encoding, errors=errors) as f:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/pathlib.py", line 1015, in open
    return io.open(self, mode, buffering, encoding, errors, newline)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmps3aswsnv/calls'

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate package test selection'

Lint every workflow

/tmp/guard-steps-qqrngr3f/runner_temp/actionlint.tar.gz: OK
/tmp/guard-steps-qqrngr3f/runner_temp/actionlint
.github/workflows/cmux-next-defaults-guard.yml:94:28: expected scalar node for string value but found alias node with "" tag [syntax-check]
   |
94 |           sparse-checkout: *defaults-paths
   |                            ^~~~~~~~~~~~~~~
.github/workflows/nightly.yml:34:3: maximum number of inputs for "workflow_dispatch" event is 10 but 15 inputs are provided. see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs [events]
   |
34 |   workflow_dispatch:
   |   ^~~~~~~~~~~~~~~~~~

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Lint every workflow'

Validate side-lane placement onto idle owned runners

  • CmuxNextWiring.test_every_mac_job_reads_the_placement (job='cmux-scheme-compile')
AssertionError: False is not true : ${{ !(github.event_name == 'pull_request' && github.base_ref == 'feat-cmux-next') && (!cancelled() && ((inputs.same_tree_sha == '' && needs.path_route.outputs.scheme == 'true' && needs.push-head-preflight.outputs.current == 'true' && needs.path_route.outputs.tree_state == 'ready' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/'))) || (inputs.same_tree_sha != '' && inputs.same_tree_state == 'ready' && contains(inputs.same_tree_tiers, 'scheme')))) }}
  • CmuxNextWiring.test_every_mac_job_reads_the_placement (job='release-compile')
AssertionError: False is not true : ${{ !(github.event_name == 'pull_request' && github.base_ref == 'feat-cmux-next') && (!cancelled() && needs.path_route.outputs.native == 'true' && needs.push-head-preflight.outputs.current == 'true' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/'))) }}
  • CmuxNextWiring.test_every_mac_job_reads_the_placement (job='swift-test')
AssertionError: False is not true : ${{ !(github.event_name == 'push' && github.ref == 'refs/heads/feat-cmux-next') && (!cancelled() && needs.path_route.outputs.swift == 'true' && needs.push-head-preflight.outputs.current == 'true' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/'))) }}
  • CmuxNextWiring.test_every_mac_job_reads_the_placement (job='daemon-test')
AssertionError: False is not true : ${{ !((github.event_name == 'push' && github.ref == 'refs/heads/feat-cmux-next') || (github.event_name == 'pull_request' && github.base_ref == 'feat-cmux-next')) && inputs.same_tree_origin != 'push' && (!cancelled() && ((inputs.same_tree_sha == '' && needs.path_route.outputs.daemon == 'true' && needs.push-head-preflight.outputs.current == 'true' && needs.path_route.outputs.tree_state == 'ready' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/'))) || (inputs.same_tree_sha != '' && inputs.same_tree_state == 'ready' && contains(inputs.same_tree_tiers, 'daemon')))) }}

... and 1 more failing tests in the run log.
Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate side-lane placement onto idle owned runners'

Validate every workflow guard is wired

Test: python3 tests/test_ci_workflow_guards_are_wired.py

Traceback (most recent call last):
  File "/home/runner/_work/cmux/cmux/tests/test_ci_workflow_guards_are_wired.py", line 110, in <module>
    test_every_workflow_guard_is_run_by_a_workflow()
  File "/home/runner/_work/cmux/cmux/tests/test_ci_workflow_guards_are_wired.py", line 89, in test_every_workflow_guard_is_run_by_a_workflow
    assert not unwired, (
           ^^^^^^^^^^^
AssertionError: these tests read .github/workflows/ but no workflow runs them, so the invariants they assert are not enforced:
  tests/test_cmux_tui_republish_same_commit.py
Add a step to .github/workflows/ci-guards.yml, or add an entry to UNWIRED in this file explaining why not.

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate every workflow guard is wired'

Validate notification semantics gates

Test: python3 tests/test_ci_notification_semantics.py

  • NotificationSemanticsTests.test_a_failing_package_does_not_hide_later_packages
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp6mqlcc0q/calls.jsonl'
  • NotificationSemanticsTests.test_packages_prebuild_in_parallel_then_test_serially
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmpfbap0y7p/calls.jsonl'

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate notification semantics gates'

Validate the macOS cross-compile shadow jobs stay compare-only

  • ShadowJobTests.test_every_shadow_job_is_compare_only (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-shadow')
AssertionError: Lists differ: ['notify-unpublished-tree'] != []

First list contains 1 additional elements.
First extra element 0:
'notify-unpublished-tree'

- ['notify-unpublished-tree']
+ [] : cmux-tui-artifacts.yml: ['notify-unpublished-tree'] must not need macos-cross-shadow
  • ShadowJobTests.test_every_shadow_job_is_compare_only (workflow='cmux-tui-artifacts.yml', shadow='macos-cross-runtime-x86_64')
AssertionError: Lists differ: ['notify-unpublished-tree'] != []

First list contains 1 additional elements.
First extra element 0:
'notify-unpublished-tree'

- ['notify-unpublished-tree']
+ [] : cmux-tui-artifacts.yml: ['notify-unpublished-tree'] must not need macos-cross-runtime-x86_64

Fix: the assertion above names what the guard expects; change the tree to match it.
Reproduce in seconds, no build: scripts/ci/guards-local.sh --step 'Validate the macOS cross-compile shadow jobs stay compare-only'

... and 2 more failed steps in the run log.

Agents: python3 scripts/ci/guard_attribution.py fix applies the mechanical fixes locally.

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Correctness-first review (fresh subagent) and resolution

Isolation: no leak found.

  • The hub name comes only from the verified token: user:<owner key>.
  • The path teamId is used only for membership and filtering.
  • Push and notify are owner-checked. /teams/:id/macs reads only the caller's rows.
  • Owner keys cannot collide across Stack projects, and old team-hub names can never start with user:.
  • A signal to another user's device gets not_owner whether the device is online or not, so no existence oracle.
  • No blocker.

Should-fix, all fixed in 0f6b3e64007:

  1. A second retireTeamHub on the same live object threw. deleteAll dropped the tables and the constructor does not rerun. The failure also retried on every request. Fixed: the tables are recreated after deleteAll, already-closed sockets are skipped, and a failure is logged instead of retried. Test: retire twice on one live object.
  2. During the deploy overlap, an older Worker could bind an owner into a team hub, which then escaped retirement. Fixed: bind accepts only an owner key whose idFromName("user:" + key) equals the object's own id, and retireTeamHub skips only such a hub. Test: a team hub with an owner row written into it is still retired.
  3. Seeding could bring back a Mac deleted between the database read and the seed. Fixed: the hub keeps a forgotten list that seeding skips (re-registering clears it), and a hub seeds only once. Test: a stale seed after a delete does not list the Mac.

Nits:

  • Fast path admits on the hub's stale team row if register failed after the database write. The client got a 500 for that POST /v1/devices. Impact is low: same user, still a member, and admission is by user. Left as is.
  • A socket may keep signaling the user's own Mac after the user leaves a team. This matches the documented rule (admission is by user).
  • Every isolate's first retire of a team creates tiny storage. Cost only, until the migration shim is removed.

Test gaps added:

  • a device id deleted by one user and registered by another is admitted only for the new owner;
  • retire twice;
  • an owner-bound team hub;
  • a stale seed.

Cross-project same-sub isolation was already covered in signal.test.ts ("production users signal their own Macs; the same sub in dev cannot").

After the fixes: 57/57 Worker tests pass. On the redeployed cnx-ios-api-userhub-dev, the live cross-user check passed 18/18, WebRTC connected mini to mini (direct, 378 ms) and WireGuard connected GCP to mini (direct, 788 ms, 63 ms RTT).

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Deployed 0f6b3e6 (API 0.10.0) on the user's directive, 2026-10-20:

  • dev cnx-ios-api-dev version a0f18ffb: live smoke (personal space) 28/29 pass, including relay frame refused, other personal space refused, foreign team refused, presence. The one failure is notify: a fake push token counted as failed instead of dropped, in push code this PR does not touch.
  • prod cnx-ios-api-prod version b192f184: /v1/health reports 0.10.0; unauthenticated /v1/me/macs is 401.
  • Removed the temporary cnx-ios-api-userhub-dev Worker.
    Not merged.

azooz2003-bit and others added 5 commits October 19, 2026 20:57
A retire RPC can fail while the hub restarts onto the new class during a
deploy; since 0f6b3e6 the isolate then never retries, and a Mac stays
parked in its old team hub, offline to its phone (transport verify
finding 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach minute

Root cause of transport-verify finding 5: around the 0.10.0 deploy the
lazy retire RPC reached team hubs still on the previous class ("The RPC
receiver does not implement the method retireTeamHub"). 0f6b3e6 made
an isolate remember a team as retired before the RPC succeeded and never
retry, so the Mac's socket stayed parked in its team hub and its phone
saw it offline until the socket dropped.

- Only a successful retire is remembered; a failure is retried after
  10 s.
- A minute cron (retireAllTeamHubs) retires the hub of every team id in
  devices and team_prefs, in each project's naming, without waiting for
  a request. A retired hub records it, so later sweeps do no writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live on a repro Worker: after a deploy, steady retire calls kept the team
hub warm on the previous class for minutes; every retireTeamHub failed
with "does not implement" and the Mac stayed parked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Durable Object keeps the previous class after a deploy until evicted,
and traffic keeps it warm, so retireTeamHub could fail for minutes. On
"does not implement", call the previous class's forgetMac for each
device the database has in that team (closes 4001 moved; hostd and the
app reconnect, into their user hub). The cron sweep and lazy retires
pass the team's device ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Incident: Macs stayed parked in old team hubs after the 0.10.0 deploy (transport-verify finding 5), fixed

Root cause. After a deploy, the Worker serves the new code at once. A Durable Object keeps running the previous class until it is evicted. Steady traffic, such as a polling phone or the retire calls themselves, can keep it warm for minutes.

  • A team hub still on the previous class answers retireTeamHub with TypeError: The RPC receiver does not implement the method "retireTeamHub".
  • 0f6b3e64007 recorded the team as retired in the isolate before the RPC succeeded and never retried. The rig Mac's socket (opened 03:09:13 UTC on 0.9.0) therefore stayed in its team hub with no 1012 until it was killed at 03:22:25, according to its hostd log.
  • Its phone read presence from the user hub and showed the Mac offline.
  • Retrying alone is not enough. On a repro Worker, retries every 10 s kept the old instance warm, and every retry failed for over 2 minutes.

Reproduced live on a separate Worker: 0.9.0 base, an old cnx-hostd (Oct 14) connected, then a deploy of 0f6b3e64007 while polling /v1/me/macs like the phone. The Mac showed offline from 10 s after the deploy. In the tail, every retire failed with "does not implement".

Fix (1f7ad117d7a, 7545bed6dfb, 3586829a097; the regression tests were committed red first, in c56c0725d5a and d0d50e07cfd):

  1. Only a successful retire is remembered. A failure is retried 10 s later.
  2. When the hub is on the previous class, the Worker calls that class's own forgetMac(deviceId), which every version since the first Worker has. It runs for each device the database has in that team. It closes the device's socket with 4001 moved. hostd and the app reconnect on 4001 (only 4002 stops hostd), now into their user hub. The hub is fully retired once it runs the new class.
  3. Proactive: a cron runs every minute (retireAllTeamHubs). It walks every team id in devices and team_prefs, using each project's hub naming, and retires each one, without waiting for a request. A retired hub records it, so later sweeps do no writes. Each sweep logs team hub sweep {teams, retired, closed, failed}.

Tests: 60/60 Worker tests pass. New tests cover:

  • a failed retire retried by a later request (red on 0f6b3e64007);
  • a hub kept on the previous class, whose parked Mac is closed with 4001 (red on 1f7ad117d7a);
  • the cron sweep in both projects' naming.

Verified live on the repro Worker: 0.9.0 base, old hostd connected, then a fix deploy while polling every 2.5 s.

  • Only one poll showed the Mac offline, at the deploy (04:21:19).
  • At 04:21:20 hostd logged signaling closed by server: 4001 moved, and it was connected again at 04:21:21.
  • The tail shows team hub on the previous class; devices moved {"devices":1,"moved":1} from both the request and the sweep paths, and the cron firing every minute.

Rollout:

  • Dev cnx-ios-api-dev: version cd3fc3ce-a591-46db-877e-152baf265144 (04:33 UTC). Sweep: {"teams":7,"retired":7,"closed":0,"failed":0} every minute.
  • Prod cnx-ios-api-prod: version 9cb3f67c-dec3-4a61-97a5-599528246bbe (04:36:21 UTC). The cron started at 04:45:42, about 9 minutes after the deploy; new cron triggers take minutes to register. Every sweep since reports {"teams":6,"retired":6,"closed":0,"failed":0}, so all 6 prod team hubs are retired and none held a parked socket.

The repro Worker was deleted after the test. Its test user's data was purged.

- scheduled awaits the sweep and logs a failure instead of an unhandled
  rejection.
- The sweep skips teams with no devices (no hub to create) and ignores a
  request's 10 s backoff; the signal path passes its device id.
- A bad TEAM_HUB_RETIRE_RETRY_MS falls back to 10 s.
- PROTOCOL: the fallback reaches devices the database has in that team;
  the sweep is temporary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Review of the incident fix (fresh subagent) and resolution

No blocker. The subagent confirmed:

  • the Postgres sweep query is correct: the NULL in UNION ALL takes the uuid type, and owner-key parsing matches scoped();
  • hub naming matches the old team hubs;
  • the forgetMac fallback cannot touch new-class hubs or user hubs;
  • clients reconnect on 4001;
  • the memo cannot grow without bound.

Fixed in 05427a94a76:

  • The scheduled handler now awaits the sweep and logs team hub sweep failed instead of leaving an unhandled rejection.
  • The sweep skips teams with no devices: those never had a socket, and sweeping them would create a hub. It also ignores a request's 10 s backoff, because it carries every device of the team.
  • The signal path now passes its device id to the fallback.
  • TEAM_HUB_RETIRE_RETRY_MS: a malformed value falls back to 10 s.
  • PROTOCOL.md documents the gap: the fallback reaches devices the database currently has in that team, so a phone that switched team stays parked until the hub runs the new class. It also notes the sweep is temporary.

Redeployed:

  • dev cnx-ios-api-dev: f11b6e54-f666-4a4b-b5be-8956446b9985
  • prod cnx-ios-api-prod: c00512ed-3ef1-4005-9e1e-c425d91e40b7

Sweep logs at 05:00 UTC:

  • dev {"teams":6,"retired":6,"closed":0,"failed":0}
  • prod {"teams":5,"retired":5,"closed":0,"failed":0}

The counts are one lower than before because prefs-only teams are no longer swept. 60/60 Worker tests pass.

azooz2003-bit added a commit that referenced this pull request Oct 20, 2026
…into feat-cnx-ios-dogfood-20261020

# Conflicts:
#	cnx-ios/backend/src/env.ts
#	cnx-ios/backend/test/http.test.ts
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Superseded by #22555, which merged this branch's head into feat-cmux-next (merge commit f3842ff) together with #22260, #22354, #22355, #22337, #22419, #22387 and #22574. Closing as folded in; the branch is kept.

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