Skip to content

fix(ios): never signal a recycled runner pid from a stale lease - #1621

Merged
thymikee merged 3 commits into
mainfrom
fix/runner-lease-pid-reuse-1596
Aug 5, 2026
Merged

fix(ios): never signal a recycled runner pid from a stale lease#1621
thymikee merged 3 commits into
mainfrom
fix/runner-lease-pid-reuse-1596

Conversation

@thymikee

@thymikee thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member

Part of #1596.

What the #1596 evidence actually shows

Investigating the three element-17/20/29 zero-action deaths refuted the issue's headline mechanism — the daemon replace path did not kill the calling command (full evidence posted on the issue). But the audit surfaced a real adjacent defect on the exact code path the issue suspected:

The defect

cleanupLeasedRunnerProcesses kills lease.runnerPid rawkill(-pid) + kill(pid) + pkill -P pid — from an on-disk lease file. The lease owner pid gets start-time identity verification; the runner pid gets none. A SIGKILLed daemon leaves its lease behind for days, pids get recycled (this machine's pid space observably wrapped within hours under bench load), and the next daemon's stale-lease cleanup then tree-kills whatever innocent process — including its whole process group — now holds that pid.

The fix

  • RunnerLease records runnerStartTime at construction (both the fresh-spawn and adoption sites already go through buildRunnerLease).
  • Before the SIGTERM/SIGKILL tree kills, the pid must be proven to still be the leased runner: recorded start-time match, or — for legacy leases without one — a runner-shaped xcodebuild … AgentDeviceRunner command line. Otherwise the tree kill is skipped and a ios_runner_lease_recycled_pid_skipped warn diagnostic is emitted.
  • The pattern-based xcodebuild pkill -f runs unconditionally as before — genuinely stray runner processes are still collected; only the unverifiable raw-pid signal is withheld.
  • Adoption (tryAdoptRunnerSessionFromLease) skips a recycled pid too: the adopted session's later disposal would signal it.

Tests

  • 4 new stale-lease cleanup tests through prepareRunnerLeaseForStartup with a recording adapter: recycled pid skipped, matching pid killed, legacy lease trusts only runner-shaped commands, dead pid skipped — all asserting the xcodebuild pattern cleanup still runs.
  • 1 new adoption test: recycled pid is not adopted.
  • vitest (83/83 in touched files), typecheck, lint green.

A runner lease file can outlive its runner by days (SIGKILLed daemon),
and pids get recycled: cleanupLeasedRunnerProcesses killed lease.runnerPid
raw — group kill + direct kill + pkill -P — while only the lease OWNER pid
had identity verification. After a pid-space wrap the tree kill lands on
whatever process now holds the pid.

- record runnerStartTime in the lease at construction (both the fresh-spawn
  and adoption sites go through buildRunnerLease)
- verify the pid before the SIGTERM/SIGKILL tree kills: start-time match,
  or for legacy leases without one, a runner-shaped xcodebuild command line;
  otherwise skip the tree kill and emit ios_runner_lease_recycled_pid_skipped
- the pattern-based xcodebuild pkill still runs unconditionally, so genuinely
  stray runner processes are still collected
- adoption skips a recycled pid too: the adopted session's disposal would
  later signal it
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.97 MB 1.97 MB +619 B
JS gzip 631.7 kB 631.8 kB +189 B
npm tarball 761.6 kB 761.8 kB +186 B
npm unpacked 2.67 MB 2.67 MB +619 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 27.1 ms -0.4 ms
CLI --help 65.1 ms 64.9 ms -0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runner-client.js +103 B +21 B

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Not ready — two P1 process-safety gaps remain:

  1. cleanupLeasedRunnerProcesses verifies runnerPid once, then reuses it for SIGTERM and SIGKILL with an awaited pattern cleanup in between. If TERM ends the original runner and the PID is recycled before KILL, the second raw tree kill can still hit an innocent process. Re-verify identity immediately before every raw signal; add a regression where start time changes after TERM and KILL receives no PID.

  2. Adoption only checks identity when runnerStartTime exists. A legacy lease with no timestamp can adopt a recycled live PID if the leased port still answers, and later disposal will signal that unrelated PID. Apply the same fail-closed/runner-shaped-command fallback used by cleanup and cover foreign legacy PID + healthy probe => no adoption.

The iOS Smoke check is also red (wait capture stalled with zero readable captures). It may be an unrelated infrastructure/capture flake, but it is a confirmed failing gate. Other static, integration, Android/macOS, and Swift checks are green. Part of #1596 is correctly scoped.

…y adoption

The stale-lease cleanup verified the runner pid once and reused it for
both SIGTERM and SIGKILL; the runner usually dies on the SIGTERM, and
the pid can be recycled while the awaited xcodebuild sweep runs before
the escalation. Each signal now resolves a freshly verified pid.

Adoption skipped identity verification entirely for legacy leases
without a recorded runnerStartTime, then re-stamped the lease with the
live pid's start time — laundering a recycled pid into a strongly
verified lease that disposal would later kill. Adoption now shares the
disposal path's verification, including the runner-shaped command-line
fallback for legacy leases.
@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Both P1s addressed in 3e128b2:

P1 — verify-once across TERM→KILL: cleanupLeasedRunnerProcesses now resolves a freshly verified pid immediately before each signal instead of reusing one resolution. The realistic window was exactly the one flagged: the runner usually dies on the SIGTERM, and the awaited xcodebuild pattern sweep between the signals gives the kernel plenty of time to recycle the pid before the SIGKILL escalation. After the change, the second resolve returns undefined for a dead pid and refuses (with the recycled-pid diagnostic) a recycled live one. The residual verify→signal race is now the microsecond readProcessStartTimekill(2) window, which is as tight as it gets without pidfd-style primitives (macOS has none). Regression test: identity flips between the two signals → SIGTERM lands on the verified pid, SIGKILL gets undefined.

P1 — legacy-lease adoption bypass: the start-time check in tryAdoptRunnerSessionFromLease was lease.runnerStartTime && …, so a legacy lease skipped verification entirely — and adoption then re-stamped the lease with the live pid's start time, laundering a recycled pid into a strongly-verified lease that disposal would later confidently kill. Adoption now shares the disposal path's verification helper (verifyLeaseRunnerPidIdentity), including the runner-shaped command-line fallback for legacy leases. Two regression tests: non-runner-shaped live pid on a legacy lease is refused before the port probe; runner-shaped one still adopts.

iOS Smoke: agreed it reads as the known wait-capture flake; this push re-rolls it — will confirm it greens before calling this ready.

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

The two prior P1s are fixed and their regressions are meaningful, but one P1 race remains in adoption. PID identity is verified, then probeRunnerAnswersUptime is awaited before buildAdoptedRunnerSession/buildRunnerLease re-stamps the PID. The original xcodebuild can exit and its PID can be recycled during that probe while the old port still answers, causing the recycled PID to be adopted and later tree-killed. Re-verify the same lease/PID identity immediately after the probe and before constructing or writing the adopted session. Add a regression where identity matches initially, changes while the successful probe is in flight, and adoption returns null.

No ready label yet. The PR is conflict-free and all completed checks are green; iOS/Android Smoke, Coverage, and FreeRange are still running.

The uptime probe is the last await before the adopted lease re-stamps
the pid with its live start time; the xcodebuild can exit and its pid
be recycled during that network round-trip while the old port still
answers. Re-verify after the probe — everything from there to the lease
write is synchronous.
@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Fixed in 2ef6c0c: adoption now re-verifies isProcessAlive + verifyLeaseRunnerPidIdentity immediately after the awaited uptime probe, before buildAdoptedRunnerSession/writeRunnerLease — and everything from that check to the lease write is synchronous, so the probe was the last await that could hide a recycle. Regression test added: identity matches at the first check, flips inside the (successful) probe, adoption returns null and the stale lease on disk survives untouched (ownership never transferred).

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 2ef6c0c7d. The post-probe PID identity recheck closes the remaining adoption race: it runs after the final await and before synchronous session/lease construction. The new regression is non-vacuous and proves a PID recycled during a successful probe is refused without transferring lease ownership. The earlier per-signal cleanup and legacy-adoption fixes remain intact. No remaining code finding; mergeable and conflict-free. Completed checks are green, with iOS/Android Smoke, Coverage, and FreeRange still running.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 5, 2026
@thymikee
thymikee merged commit 3b1431c into main Aug 5, 2026
30 checks passed
@thymikee
thymikee deleted the fix/runner-lease-pid-reuse-1596 branch August 5, 2026 16:37
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-05 16:38 UTC

thymikee added a commit to szdziedzic/agent-device that referenced this pull request Aug 6, 2026
* origin/main:
  chore: Update GitHub Sponsors usernames in FUNDING.yml
  fix: resolve Dependabot security alerts (callstack#1623)
  fix: update MCP registry namespace (callstack#1618)
  fix(ios): corroborate recorded tap outcomes (callstack#1605)
  fix(ios): never signal a recycled runner pid from a stale lease (callstack#1621)
  build: eliminate tsdown bundle warnings (callstack#1607)
  refactor(contracts): one viewport-root predicate for the whole repo (callstack#1613)
  refactor(contracts): name façade exports explicitly and retire the pin table (callstack#1614)
  refactor(ios): share one private-XCTest event bridge between gesture and text synthesis (callstack#1608)
  refactor(daemon): give the Maestro fallback and ambiguous-match details real types (callstack#1612)
  docs: clarify iOS drag synthesis profiles (callstack#1616)

# Conflicts:
#	scripts/layering/facade-symbols.ts
#	src/commands/capture/screenshot.ts
#	src/commands/recording/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant