Skip to content

fix: heal dangling XCTestDevices redirect and prove lock-owner death before reclaim - #1672

Merged
thymikee merged 3 commits into
mainfrom
claude/amazing-banach-482a7d
Aug 7, 2026
Merged

fix: heal dangling XCTestDevices redirect and prove lock-owner death before reclaim#1672
thymikee merged 3 commits into
mainfrom
claude/amazing-banach-482a7d

Conversation

@thymikee

@thymikee thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member

Two reproducible machine-wedging failures in the shared XCTest device-set machinery, hit while live-validating on a heavily loaded shared dev machine. Both left every subsequent XCTest command on the machine failing until manual cleanup.

1. Dangling XCTestDevices symlink defeats reconcile (ENOTDIR wedge)

reconcileXcodebuildSimulatorSetRedirect detected symlinks via fs.existsSync(...) && fs.lstatSync(...).isSymbolicLink(). existsSync follows symlinks, so a redirect whose target set was deleted (e.g. a caller-cleaned scratch --ios-simulator-device-set) read as absent: reconcile skipped the unlink and renameSync(backup, XCTestDevices) failed with ENOTDIR against the still-present dangling entry — on every future self-heal attempt too. unlinkIfSymlink had the same existsSync guard, so fixing only the detection wouldn't have healed anything.

Both sites now detect symlinks with lstatSync(..., { throwIfNoEntry: false }). Regression test verified red (ENOTDIR path) on the old code.

2. Lock-owner liveness: zombies read as live, live owners read as dead

The xctest-device-set.lock stale-owner reclaim had two inverse holes, confirmed empirically:

  • Held forever: a zombified (killed-but-unreaped) owner passes kill(pid, 0) and still reports its original lstart, so both existing guards read it as live. Verified with a real zombie: state ZN, original start time intact.
  • Stolen while held: a ps read lost to CPU contention (1s timeout) returned null, mismatched the recorded start time, and condemned a genuinely-live owner — waiters then reclaimed a held lock mid-redirect. The test suite already papered over exactly this in pinOwnProcessStartTime (its docstring describes the flake verbatim); that was a production defect surfacing as test flake, and a plausible origin of the dangling-symlink state above.
  • Residual hole: owners recorded with startTime: null (ps lost at acquire) plus PID reuse read as live indefinitely.

Fix unifies both liveness surfaces (isLiveProcessLockOwner, #1647's classifyOwnerLiveness) on one proof-oriented classifier:

  • zombies are dead (ps -o state=) — they can never release anything;
  • a failed ps read is not proof of death and never condemns an alive pid;
  • a null-start-time owner is dead once its pid provably started after acquisition (ps -o etime= bound, 30s slack) — a process born after the lock was acquired cannot be the acquirer.

Runner leases and device claims inherit the zombie/no-steal semantics through the shared function. Lock timeout errors now include ownerLiveness so the next field occurrence immediately separates genuine contention from a classifier miss.

Testing

  • New classification tests fail 3/6 on the old code — exactly the three changed behaviors — and pass on the new.
  • End-to-end: zombie-owned and started-after-acquisition locks are reclaimed; dangling-symlink reconcile restores the backup.
  • utils + daemon + apple-core suites, lint, typecheck, format, fallow, layering all green post-rebase.

Follow-up candidate: pinOwnProcessStartTime in host-process-mock.ts now guards a flake that can no longer occur and is likely removable.

…cile

fs.existsSync follows symlinks, so a redirect symlink whose target set was
deleted read as absent: reconcile skipped the unlink and the backup rename
failed with ENOTDIR, wedging every XCTest command on the machine until manual
cleanup. Detect symlinks with lstat (throwIfNoEntry) in both the reconcile
check and unlinkIfSymlink so the stale link is removed and the backup restored.
A zombified lock owner passes kill(pid, 0) and still reports its original
lstart, so a killed-but-unreaped daemon held the XCTest device-set lock
forever. Conversely a ps read lost to CPU contention condemned a live owner
and let waiters steal a held lock (the flake pinOwnProcessStartTime papers
over in tests).

Unify both liveness surfaces on classifyOwnerLiveness: condemn zombies via
ps state, treat failed ps reads as no-proof rather than death, and reclaim
null-start-time owners whose pid provably started after the lock was acquired
(ps etime bound). Lock timeout errors now report ownerLiveness.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.98 MB 1.98 MB +83 B
JS gzip 641.3 kB 641.4 kB +48 B
npm tarball 769.2 kB 769.3 kB +96 B
npm unpacked 2.69 MB 2.69 MB +83 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 27.7 ms +0.4 ms
CLI --help 65.3 ms 65.5 ms +0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runner-disposal.js -24 B +4 B

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head 126e309fd.

The dangling-XCTestDevices symlink repair is sound, and its production-path regression would fail on the old existsSync behavior. Zombie detection and the transient-ps fail-closed change are also directionally correct and covered by non-vacuous tests.

P1 — the null-start PID-reuse fallback can still steal a live lock after a clock step. readProcessStartedAtMs derives a wall-clock birth time as Date.now() - ps etime, then compares it with the persisted wall-clock acquiredAtMs. A wall-clock step larger than the fixed 30s margin can make a genuinely live null-start owner appear to have started after acquisition, causing a waiter to reclaim the held XCTest redirect lock—the same machine-wedging failure class this PR is intended to eliminate. A fixed slack is not proof of owner death.

Please either keep null-start identities fail-closed, or persist and compare a same-domain monotonic/boot-relative acquisition marker, with a regression that simulates a clock step while the original owner is still live.

All completed checks are green; iOS Smoke is still in progress.

Review P1 on #1672: the started-after-acquisition bound compared a wall-clock
birth estimate (Date.now() - ps etime) against the persisted wall-clock
acquiredAtMs, so a clock step larger than the fixed 30s slack could condemn a
live null-start owner and let a waiter steal the held lock — the failure class
this PR eliminates. There is no same-clock-domain proof of birth order to be
had here (macOS ps etime is itself wall-derived), so drop the bound: an alive
pid with no recorded start time is never reclaimed. Regression covers the
steal at both the classifier and the lock level.
@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Addressed the P1 in 9af92d5 — you're right, and the steal reproduces: with the etime bound in place, a null-start owner whose acquiredAtMs reads far in the past (exactly what a wall-clock step manufactures) gets reclaimed while alive.

Went with the fail-closed option rather than a monotonic marker: on macOS ps etime is itself wall-derived (now - ki_start), so even a persisted boot-relative acquisition marker gets compared against a step-contaminated elapsed value — the domains can't be cleanly separated with what ps exposes. Dropped readProcessStartedAtMs and the acquiredAtMs bound entirely; an alive pid with no recorded start time is never condemned.

Regressions: classifier-level (a null-start-time owner stays fail-closed while its pid is alive) and lock-level (acquireProcessLock never steals a null-start-time lock from an alive pid, seeding acquiredAtMs a year in the past to simulate the post-step view; asserts the waiter times out with ownerLiveness: 'live'). The lock-level test fails on the previous commit (waiter steals the lock) and passes now.

The residual held-forever hole (null start time + pid reuse) is accepted; its precondition is ps losing at acquire time. If it shows up in the field, the ownerLiveness: 'live' diagnostic will finger it, and shrinking the precondition (retrying the start-time read at acquire) is a cheap follow-up.

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed 9af92d58: the clock-domain P1 is fixed. Null-start owners now remain fail-closed while their PID is alive; the wall-clock/ps etime inference is removed, and the lock-level regression simulates the dangerous old-age view and proves a waiter times out instead of stealing the held lock. The earlier dangling-symlink, zombie, and transient-ps fixes remain sound. Code review clean; ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 7, 2026
@thymikee
thymikee merged commit 9b4239b into main Aug 7, 2026
30 checks passed
@thymikee
thymikee deleted the claude/amazing-banach-482a7d branch August 7, 2026 12:33
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-07 12:34 UTC

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