Repository navigation
fix: enforce device claims for sessionless device mutations (#1799) - #1809
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed exact head b807323. Clean code/architecture verdict: claim policy is descriptor-required, transient-exclusive admission sits at the request-runtime binding seam before operations are exposed, legacy classifications are gate-rejected, and the two-daemon regressions plus live Android A/B directly prove #1799 and cleanup. Readiness is currently blocked by exact-head CI: Android Smoke failed in the unrelated live scroll helper ( |
|
Re the Android Smoke failure at b807323: the failing step is |
|
Android Smoke rerun on the same head passed → the earlier |
b807323 to
01fcba5
Compare
|
Tightening pass after review — rebased onto current Before → after (this PR's diff vs
What changed:
|
|
Clean re-review at 01fcba5: the scope split is coherent—daemon-stop claim reporting moved to #1818, while #1809 retains the descriptor-owned claim policy, runtime-binding admission, transient lifecycle, and the exact foreign-claim boot/shutdown regressions for #1799. No shutdown-reporting files remain in this diff. Exact-head CI and all platform smokes pass; merge state is CLEAN. Ready-for-human. |
`boot` and `shutdown` never consulted the host-global device claim store, so a daemon in one state directory could terminate an emulator another daemon held a verified-live claim on and report success (#1799). Rather than adding a claim check to those two handlers, this makes the class unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait (#1320's vocabulary), and the request-execution scope enforces it where the request runtime bindings create a device binding — the one seam through which any handler can obtain device operations, and already the place per-device deduplication lives. A `transient-exclusive` command acquires a command-scoped claim before operations reach the handler, refuses a foreign live claim with the existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the scope's finally. Every other policy performs no claim-store I/O, so session-bound commands keep #1320's non-goal intact.
01fcba5 to
36083a9
Compare
|
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
#1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there.
… it (#3714) * fix(daemon): scope a transient device claim to the session that holds it #1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there. * test(integration): run the raw file push from inside the claiming workspace #3345's coverage rule pairs the routed session with the workspace that took the claim. The raw push request carried a temp cwd, so it routed under another workspace, and the open session's claim no longer covered it. Running it from a directory of the checkout reproduces the real CLI owner shape the client calls above already form, and keeps the relative payload resolution the test is for. Probe evidence at the pre-fix commit: the push refused with DEVICE_CLAIM_LIVE_OWNER naming the checkout workspace. * fix(daemon): record which kind of owner holds a device claim An in-flight command's transient claim and an open session's claim differed only by the shape of the claim's `session` label, so a request whose routed session string happened to equal `transient:<command>` — reachable today with `--session transient:install`, which the store sanitizes only in directory segments — matched the coverage branch and ran while the first request still held the device (review r4295092657). The record now stores the kind: `transientCommand` names the scoped command (schema v3), and coverage admits only open-session claims; a command-scoped claim is a live owner to every request but its holder, proven by the release token at the admission layer. Legacy v1/v2 records translate the label that encoded the kind, once, in the record decode (tag history: v2 is released in v0.21.28, so live records use it). A session literally named this command's label is refused rather than renew-adopted, so its claim keeps only what its principal grants. Fold in the review's documentation findings: the DEVICE_IN_USE help names in-flight commands as owners a caller cannot close (r4295092708 sibling), commands.md and sessions.md state the workspace condition next to session ownership without implying every caller sends a workspace (r4295092708, r4295092739), and sessions.md names the owner only when the claim is readable, pointing at `device status` otherwise (r4295092723). Shrink the android-lifecycle raw-push fixture under the merge-base length: the file is over the 1,000-line ratchet tripwire and may not grow, so the push payloads are written directly into the claiming workspace by a loop (Coverage job test-file-size ratchet, run 38905771494). * fix(daemon): decode legacy device claims kindless instead of guessing a kind r4295744998: released v1/v2 recorded a command-scoped claim and an open session literally named `transient:<command>` as the same record, so the label is not a kind proof, and stamping a guessed kind made the schema version alone decide whether a colon-named session's claim covered its commands. Legacy records now decode kindless, which is safe by construction rather than by guess: every consumer of a claim's kind first requires `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start time), a live process writes only v3 records whose kind is stored, and the forged-identity window that reaches a kind decision anyway is pinned by a closest-negative test: a kindless legacy record under this process's identity behaves exactly as a colon-named open session's claim — refused to foreign commands by the label guard, covering for the named session — never as a command claim any in-flight request can adopt or leak. The record test pins the kind assertions directly (ordinary legacy and current claims decode `transientCommand` undefined; the v3 kind comes only from its stored field; malformed kinds fail closed) and the android-lifecycle provenance comment states what the no-cwd client calls actually record: the daemon-side fallback workspace at the routed `default` address, joined explicitly by the pushes whose checkout-child cwd exists for relative payload resolution, not as a workspace-authority test (r4295745008, r4295745014). * test(daemon): tighten the device-claim admission tests Drop the duplicated transient-claim refusal case (its label-guard sibling already proves it) and rewrite comments that narrated mutations and review rounds into statements of the constraint each test pins. * fix(daemon): state a claim kind only from a record that states one A migrated v1/v2 record decoded with no kind at all, and coverage read an absent transientCommand as proof of an open session. The ownership check in front of that decision matches pid plus start time, and two processes agree on both whenever a start time is unreadable, so a leftover v2 command claim can outlive PID reuse and present as this daemon's own session. Decoded claims now carry a persisted claimOwnerKind. A current writer states open-session or transient-command; a record that predates the field states unstated and can hold and fence a device but can never cover a command. The kind is stored rather than derived from schemaVersion so renewal, abandon, and clear keep writing the current shape — a renewed record that kept its legacy version would be stamped with the migrated device shape and read back as inconsistent. * fix(daemon): hold an existing claim only for its own claim kind An acquirer now holds an existing claim only when the record states the very kind of claim the acquirer would write itself — the same transientCommand for transient admission — under the same session, workspace, and daemon identity. An unstated record matches no acquirer, so acquisition never adopts a migrated v1/v2 record, and an open session named after a command label never inherits an in-flight command's owner token. claimHeldDevice passes the acquirer's typed kind and command to resolveExistingClaim, the boundary every acquire route goes through, which retires the label guard in acquireTransientDeviceClaim. Ref: #3714 (comment)
Summary
Closes #1799.
Before:
shutdownonly refused a device that had an active session in its own daemon's session store, then calledshutdownTarget(). It never consulted the host-global device claim store, so a daemon in another state directory terminated an emulator another worktree held a verified-live claim on and reported success.boothad the same gap.After: a foreign live claim refuses the command with the existing
DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNERerror (retriable: false, daemonlessdevice statusrecovery hint) before any device operation exists.Design
The fix is not a claim check in the two handlers — it makes the class unrepresentable, per #1320's "Command descriptor policy" table:
CommandDescriptorgains a REQUIREDdeviceClaimPolicywith no default, using feat: add cross-worktree device ownership and safe recovery #1320's vocabulary (none|observe|require-owner|transient-exclusive|acquire-session|release-session). TypeScript forces every descriptor to declare one; a completeness/honesty test (mirroring the timeout-policy gate) pins the bounded, diffable set of everything that deviates fromrequire-owner.createRequestRuntimeBindings— the only way any handler obtains device operations — already caches one binding per device key, so admitting the claim as part of creating that binding is both the choke point and the natural per-device deduplication.bindExactDevicedeliberately bypasses the cache, so it admits its own. There is no per-handler call to forget.transient-exclusiveacquires a command-scoped claim after the gateway bind resolves (binding composes the operation catalog and mutates nothing) and before the narrowed projection reaches the handler; the request scope releases it on dispose, after the bindings it guards.stateDir+ owner identity, so a claim taken earlier in the same request (open's) can never lock the daemon out of its own device.acquireTransientDeviceClaimreuses the existing claim implementation (same lock, inspection, write, orphan reconciliation); it recordssession: "transient:<command>"sodevice statusshows an honest owner for a claim that lives milliseconds. No second claim implementation.shutdownself-guard (active session →close --shutdown) is unchanged.Classification (every command, honestly)
acquire-sessionopenrelease-sessionclosetransient-exclusiveboot,shutdown,install,reinstall,install_source,push,prepareobservedevices,capabilities,device,doctor,apps,appstatenonebatch,install-from-source,react-devtools), and local-CLI commandsrequire-ownerhover)Why the sessionless-capable mutations stop where they do. I first classified
keyboard,clipboardandtrigger-app-eventastransient-exclusivetoo. Live verification caught that as dishonest: those commands still declare ADR 0019legacyplatform execution and reach their device through dispatch, not through the request scope's device binding, so the gate never runs for them — a foreignkeyboard statusstill succeeded against a claimed emulator. They arerequire-owneruntil their platform execution migrates todevice-runtime, and the honesty test now requiresdevice-runtimeexecution fortransient-exclusive, so the same dishonest pairing cannot be declared again.Two more deliberate
require-ownerclassifications with reasons, as follow-ups:record— sessionlessrecord startleaves a recording running past the request, and a command-scoped claim released infinallywould free a device whose attributable resource is still owned (feat: add cross-worktree device ownership and safe recovery #1320: "Prevent device claims from becoming free while attributable resources remain owned").runtime(internal port-reverse) — same shape: it establishes durable per-session runtime state that outlives the request.Validation
Regression tests, proven red first
Reverted the production change (
git checkout HEAD --on the touched production files, removeddevice-claim-admission.ts), kept the tests:That
ok: trueon a foreign-claimed device is #1799 exactly. Restored, green:Coverage:
session-boot-shutdown-device-claims.test.ts— two-daemon-shaped (foreign state dir + claim):boot/shutdownrefused with the typed reason,shutdownTarget/bootTargetnever called, the owner's claim untouched, and the transient claim released after both a successful and a failing shutdown.device-claim-admission.test.ts— one table over all six policies proving claim-store I/O happens only fortransient-exclusive, plus foreign-claim refusal, same-daemon coverage, provider-owned skip, and the construction guard: the realcreateRequestExecutionScopeclaims forshutdownand not forsnapshoton the same binding call.device-claim-policy.test.ts— registry-driven completeness, the reviewed non-require-ownersets, and thetransient-exclusive ⇒ device-runtimehonesty invariant.Live device verification (Android,
Pixel_9_Pro_XL→emulator-5554)Pre-fix repro on the reported 0.20.9 build. Daemon A (state dir A) held a live claim; daemon B (different state dir, different cwd) ran:
With this branch (rebooted the AVD, both daemons on the built branch):
boot --platform android --device Pixel_9_Pro_XLfrom B returned the identical refusal. Afterclose --session fixed-ain A, B'sshutdownsucceeded ("OK: killing emulator, bye bye") and left no claim file behind.Sessionless
installfrom B while A held the claim was refused the same way, while the owner's own in-sessioninstallreached the device (it failed onINSTALL_FAILED_TEST_ONLY, an unrelated property of the helper APK I used) — the same-daemon coverage path with no claim-store collision.Cleanup: both daemons stopped, the AVD I booted shut down, no leftover claim file. iOS was not exercised; the seam is platform-neutral and
isLocalDeviceClaimTargetgates it on the admitted runtime owner exactly asopendoes.Gates
pnpm exec tsc --noEmit,pnpm format,pnpm check:affected --run(432 files / 3684 tests, layering + fallow green — fallow reports no issues in the changed files). Wire compat:pnpm check:daemon-wire-compat(151 declarations, 0 changed — this PR adds no daemon wire surface).Tradeoffs and follow-ups
boot/shutdownby design — feat: add cross-worktree device ownership and safe recovery #1320 requires auditing every sessionless device-mutating command, and the descriptor trait is what makes the audit permanent. A large share of the diff is one declaration line per descriptor, matching the existingtimeoutPolicyconvention; the interaction descriptors that were byte-identical now share aTARGETED_TOUCH_INTERACTION_TRAITSbundle beside the existingGENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, which also removes the clone group the added line would otherwise have created.keyboard,clipboard,trigger-app-eventbecome enforceable once ADR 0019 migrates their platform execution todevice-runtime;recordandruntimeneed a claim whose lifetime follows the durable resource, not the request, before they can leaverequire-owner.install/reinstall/install_source/push/prepareare newlytransient-exclusive— a behavior change for sessionless use against a foreign-claimed device (now refused).installwas live-verified both ways;push/preparewere not.