Skip to content

feat(exec): hold the device claim for an external command's lifetime - #3738

Open
thymikee wants to merge 6 commits into
mainfrom
maint/exec-device-claim-wrapper
Open

thymikee wants to merge 6 commits into
mainfrom
maint/exec-device-claim-wrapper

Conversation

@thymikee

@thymikee thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

agent-device exec --platform ios --udid <UDID> -- expo run:ios --device <UDID> holds the host-global device claim for the whole lifetime of an external command, so builds that install through the platform tools directly are fenced against agent-device sessions on the same device. Fixes #3263.

  • The claim is taken on the wrapper CLI process's own identity (no daemon route: a build outlasts any request envelope, and owner-liveness proofs already reconcile a dead owner). New honest wrapper-exclusive claim policy; the registry never claims the wrapper goes through daemon admission.
  • Claim records gain ownerKind: 'wrapper'; the daemon-supersession proof (which reads a daemon.json that can only ever describe daemons) no longer steals live wrapper claims, and claims under a dedicated <stateDir>/exec dir reconcile as owner-process-dead when the wrapper is killed.
  • The typed conflict builder moved to daemon-contracts so exec throws exactly the refusal the daemon admission seam returns.
  • Child runs with stdio inherit in its own process group; every received SIGINT, SIGHUP, SIGQUIT or SIGTERM forwards to the group (the first one owns the exit code) and the wrapper releases the claim only after the child exits — so a closed terminal or dropped SSH stops the build instead of stranding the fence. Exit code propagates (128+signum for signal exits, 127 for a missing command), and release is attempted on every exit path. macOS and Linux hosts only — exec refuses to start on win32 (UNSUPPORTED_PLATFORM, reason: unsupported-host-platform).
  • 25 files; docs: commands.md, sessions.md, README (matching selectors fence the very device the build targets). No scope expansion beyond the claim-store owner-kind fix the wrapper needs.
agent-device exec --platform ios --udid <udid> -- expo run:ios --device <udid>
agent-device exec --platform android --serial emulator-5554 -- adb install app-debug.apk
agent-device device status  # session "exec:<command>" holds the device

Validation

Tested at 5509d9a (review rounds 1–3, rebased onto 4c1c78696 which includes #3740 and #3741).

  • pnpm check:affected --run --head 5509d9a7d at the exact commit: all runnable checks passed (receipt .tmp/validation/run-X3P6fp/receipt.json). Two earlier runs at this head each hit one unrelated timing-sensitive test (snapshot-source/lifecycle timeout, then daemon-registration-owner fixture timeout) that pass in isolation and on the clean re-run.
  • Fail-before/pass-after: with the ownerKind supersession guard reverted, the wrapper-steal regression fails (acquired instead of conflict); with the forwarded-signal list narrowed back to SIGINT/SIGTERM, the real-SIGHUP case fails (claim stranded at a dead pid, child alive). Mutations (no release, no signal code, no forward, drop-second-signal) each fail their test for the named reason.
  • Unit: pnpm typecheck, pnpm lint, check:layering, check:doc-prose, help-conformance, command-doc-coverage, full packages/daemon (2770 tests), daemon-contracts, command-registry, exec suites — green. The three POSIX signal cases share one test.skipIf(IS_WINDOWS) condition.
  • Live simulator at 2665364 through the real CLI dispatch (iPhone 17 Pro ExecIssue3263R3, iOS 27.0, created and deleted for the check; 5509d9a only adds a Windows test skip): during exec … -- sleep 120, device status showed live session=exec:sleep and a second exec refused with DEVICE_IN_USE naming session "exec:sleep"; SIGTERM → rc 143 and claims store empty; SIGHUP → rc 129, the child sleep in the wrapper's group was gone, and the claims store was empty once the wrapper exited; after exit a fresh exec succeeded (rc 0).
  • Not verified: provider-leased cloud targets only hit the local inventory path (never claimable, by design); Windows is refused by policy, not exercised; the child has no controlling terminal, so tools that open /dev/tty (sudo, credential prompts) may fail — untested; Ctrl-Z stops the wrapper but not the child (job control for a session leader's detached child would need a terminal the child doesn't share).

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.32 MB 5.33 MB +6.3 kB
Package (unpacked) 5.32 MB 5.33 MB +6.3 kB
Package (download) 1.60 MB 1.60 MB +1.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.3 ms 27.8 ms -1.6 ms
CLI --help 85.3 ms 77.4 ms -7.9 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/cli/commands/exec.ts">

<violation number="1" location="src/cli/commands/exec.ts:139">
P2: Relative Windows executables such as ` .\gradlew.bat` are not resolved because this check recognizes only `/`; the spawn helper then rejects the backslash path. Recognize both path separators before calling `path.resolve`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/cli/commands/exec.ts
Comment thread src/cli/commands/exec.ts Outdated
Comment thread README.md Outdated
Comment thread website/docs/docs/commands.md
Comment thread website/docs/docs/sessions.md
Comment thread website/docs/docs/sessions.md Outdated
Comment thread src/cli/commands/exec.ts Outdated
Comment thread website/docs/docs/sessions.md Outdated
Comment thread src/commands/schema/command-overrides.ts Outdated
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3738/

Built to branch gh-pages at 2026-10-16 19:25 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread README.md Outdated
Comment thread src/cli/commands/__tests__/exec.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The exec wrapper can die on terminal hangup while the build it wraps keeps running, so the device claim does not last as long as the command (reviewed at 06e8600).

The child is spawned with detached: true (exec.ts:165). On POSIX that puts it in a new session with no controlling terminal. The wrapper forwards only SIGINT and SIGTERM (exec.ts:197). If the user closes the terminal or tab, or the SSH connection drops, SIGHUP reaches only the wrapper. The default action kills it before the finally block runs. The claim then names a dead pid, and the next acquirer reconciles it as owner-process-dead. Meanwhile expo run:ios, xcodebuild or adb install keeps mutating the device. Ctrl-\ (SIGQUIT) fails the same way. This is the race #3263 asks to close, and it contradicts the docs, which say the claim lasts exactly as long as the command and that exec releases it on every exit path.

The rule to satisfy: while the child group is alive, every terminating signal the wrapper can catch is forwarded to the group, and the wrapper releases the claim only after the child exits. Could attachSignalForwarding own one list of forwarded signals (SIGINT, SIGTERM, SIGHUP, SIGQUIT) and register and dispose all handlers from it? Please add a SIGHUP case to exec.test.ts next to the SIGTERM case. It should fail on the current code, with the wrapper killed and the child still running.

I did not run the SIGHUP behavior. It follows from Node's detached semantics on POSIX and from how terminal hangup is delivered, so treat it as likely rather than confirmed. Related and unverified: with no controlling terminal, tools that open /dev/tty (sudo or credential prompts) may fail, and Ctrl-Z stops the wrapper but not the child. Two small items: the outcome = 'unattributable' assignment at exec.ts:224 is dead because the function returns right after it, and scripts/help-conformance-sample-outputs.mjs:94 still names the old packages/daemon/src/resources/device/device-claim-conflict.ts path.

The live simulator run was at 55beffd. The later changes in exec.ts (repeated-signal forwarding, Windows refusal) are covered only by unit tests with real child processes. The exec tests call runExecCommand directly, so the cli.ts dispatch, the state dir wiring and the resolveActiveConnectionDefaults exclusion are proven only by that live run. The fail-before claim for the wrapper-steal regression and the mutation claims were checked by reading the code, not by running them.

The failing checks (Lint/Typecheck, Repo Guards, Integration, both Smoke lanes) all fail while loading test/integration/command-coverage/declarations.ts. That file calls linux.gap, a builder removed on main by #3717 and #3720, and the fix is in #3740. This diff does not touch it, so the failures look unrelated. There are no conflicts. Before merge, SIGHUP and SIGQUIT need forwarding with a SIGHUP regression test, and CI stays red until #3740 lands on main.

`agent-device exec --platform ios --udid <UDID> -- <cmd> [args...]` takes the
host-global device claim on the wrapper process's own identity, runs the child
with stdio inherit, forwards SIGINT/SIGTERM to the child's process group,
exits with the child's code (128+signum for signal exits, 127 for a missing
command), and releases the claim on every exit path. A killed wrapper's claim
carries an explicit owner kind so the supersession proof never steals a live
wrapper claim and every later acquirer reconciles a dead one as stale.

- device-claims: acquireWrapperDeviceClaim records ownerKind 'wrapper' under a
  dedicated <stateDir>/exec claim dir; the session label exec:<command> is
  sanitized like session names.
- device-claim-inspection: the daemon-supersession proof applies only to
  daemon-served claims (ownerKind absent); wrapper liveness stands.
- device-claim-conflict moves to daemon-contracts so the wrapper throws the
  same typed refusal the daemon admission seam returns.
- registry: new honest 'wrapper-exclusive' claim policy for the exec descriptor.
- docs: commands.md and sessions.md claim sections, README claim paragraph.
The wrapper-claim label sanitizing reached host-kit/session-paths statically,
growing three budgeted eager closures by one module each. Take the same
safeSessionName through a function-scoped dynamic import, as the closure
guard prescribes.
… docs

- Forward every received signal to the child group; the first one still owns
  the exit code, so a child that traps or ignores SIGINT stays stoppable by a
  later SIGTERM (r4297377444).
- Refuse to start on win32 with UNSUPPORTED_PLATFORM and
  reason unsupported-host-platform instead of half-supporting process-group
  signalling and backslash relative paths there (r4297377356, r4297377386);
  help and docs now say macOS and Linux hosts.
- Help synopsis lists every accepted selector: --target and
  --ios-simulator-device-set were declared but missing (r4297377464).
- Docs: README shows matching selectors fencing the same device the build
  targets (r4297377397); sessions.md names the local-only boundary
  (r4297377423), the attempted-release guarantee (r4297377435), the exact
  auto-reconcile condition with uncertain claims staying blocked
  (r4297377455); commands.md documents the reboot-takeover exception
  (r4297377408).
- README: the hosted-device sentence no longer covers `exec`; it holds claims
  for local devices only (r4297509439).
- exec tests: the second-signal case pins listener restoration for both
  SIGINT and SIGTERM, so a leaked onSigint fails the run (r4297509474).
… build too

The child leads its own session, so terminal hangup reached only the wrapper
and its default action killed it before the release: the claim named a dead
pid while the build kept mutating the device. attachSignalForwarding now owns
one list of forwarded signals (SIGINT, SIGTERM, SIGHUP, SIGQUIT), registers
and disposes every handler from it, and replays all signals received before
the child existed. The claim is released only after the child exits.

- exec tests: real-process SIGHUP case (fails on the previous code: wrapper
  killed, claim stranded, child alive) asserting release-before-exit, the
  child group stopping, and exit 129.
- help and docs name the full forwarded set.
- delete the dead outcome assignment in releaseExecDeviceClaim.
- fix the stale device-claim-conflict.ts path in the help-conformance sample.
@thymikee
thymikee force-pushed the maint/exec-device-claim-wrapper branch from 06e8600 to 2665364 Compare October 16, 2026 19:12

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/cli/commands/__tests__/exec.test.ts Outdated
Signal disposition, process-group kill, and a real SIGHUP's default action are
POSIX semantics, and exec refuses before claiming on a Windows host, so the
signal cases would only time out waiting for the claim there. Gate all three
from one IS_WINDOWS condition (r4298409165).
@thymikee

Copy link
Copy Markdown
Member Author

Addressed in 2665364 (+ 5509d9a for the Windows test skip), rebased onto main (4c1c786, includes #3740).

Blocking (terminal hangup). attachSignalForwarding now owns one FORWARDED_SIGNALS list (SIGINT, SIGTERM, SIGHUP, SIGQUIT), registers and disposes every handler from it, and replays all signals received before the child existed. The rule holds: while the child group is alive, every catchable terminating signal is forwarded to the group and the claim releases only after the child exits. SIGTSTP is deliberately absent (noted below). New real-process case a real SIGHUP stops the child group and releases the claim before the wrapper exits: a real wrapper process gets a real SIGHUP, and it asserts the claim is gone once the wrapper's exit is observable, the child group stopped, and rc 129. It fails on the previous code — with the list narrowed back to SIGINT/SIGTERM it dies exactly as you described: wrapper killed, claim pointing at a dead pid, child still running.

Small items. The dead outcome = 'unattributable' is gone (the catch now just reports and returns); the stale packages/daemon/src/resources/device/... path in help-conformance-sample-outputs.mjs names the real file. Help text and both docs pages name the full forwarded set.

Live run at the final head (through bin/agent-device.mjs, so cli.ts dispatch and the state-dir wiring are on the proven path; fresh iPhone 17 Pro simulator ExecIssue3263R3, iOS 27.0, deleted afterwards): device status showed live session=exec:sleep during the run and a second exec refused with DEVICE_IN_USE naming exec:sleep; SIGTERM → rc 143, claims store empty; SIGHUP → rc 129, the child sleep in the wrapper's group was gone, claims store empty once the wrapper exited; after exit a fresh exec succeeded rc 0.

Unverified side effects — recorded under "Not verified" in the PR body: the child has no controlling terminal, so /dev/tty openers (sudo, credential prompts) may fail (untested), and Ctrl-Z stops the wrapper but not the child — job control for a session-leader's detached child would need a controlling terminal the child doesn't share, so SIGTSTP stays un-forwarded by design.

The CI-red checks you listed are the #3717/#3720 builder gap on main; this head rebases over #3740, so they should clear.

This branch has not been deployed

No deployments
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.

Let a script hold a device claim while it runs simctl install or simctl launch itself

1 participant