fix(apple-runner): fence prep spawns behind a start-owned admission - #3239
Conversation
Size Report
Startup median (7 runs, lower is better):
|
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
The fence design in d8644c0 is not ready, and the live evidence for #3220 is missing. Smoke Tests is still running, and the diff touches ensureRunnerSession, the non-retained close and the iOS runner build spawn, so a failure there is not presumed unrelated. I did not run any tests; I judged the regression tests by reading the pre-change route. The live run in the PR settled the same way on origin/main, so no retry ever reached the spawn fence (runner-session.ts#L759). It proves close during build, not the refused replacement build, so the "no respawn" done-when in #3220 is not shown. Please run one live iOS simulator run: start a cold The fence is a device-global registry that refuses every newcomer while a teardown runs. The retry in the prepare loop ( Two open inline threads still stand: concurrent callers share one admission before registering (P1), and fence cleared by any teardown. Also raw signal ignores requestId-only owners, idle-stop open gets closed admission, and zero-signal test registers no prep child still apply. The env isolation thread does not apply: the forks pool keeps isolation on, and both tests reassign the env in Before merge, count every caller that can still cancel, including requestId-only daemon owners, as an interested waiter, registered before the first await. Make the fence refuse only the retiring start's own retries, not idle-stop or concurrent-teardown newcomers. Then add the live refused-retry evidence above. |
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
127db94 to
5d74815
Compare
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
5d74815 to
1efebe8
Compare
|
Thanks for the update. At 1efebe8 all 19 checks pass and there are no conflicts. The first-round fixes are in: close no longer waits on cold starts that have no session, and the lock-holding owner now registers its resolved request signal. Two defects remain, so this is not ready to merge. The fence still decides by lock-queue order, not by which start it retires. A caller with only a requestId is still not counted as a waiter until it holds the lock. Could a smaller design cover both? The retry loop would own one admission token, created in The open inline threads still apply for the vacuous zero-signal assertion in runner-start-budget.test.ts (r4187333221), the queued requestId-only starts (r4187333211) and the shared fence deleted by overlapping teardowns (r4187333242). These can be resolved: r4187333227 (the forks pool isolates workers and beforeEach reassigns both env vars), r4187333249 (idle-stop and speculative newcomers wake after the clear and re-route), r4188785937 and r4188785949 (rebase artifacts, the diff since 14e1bd7 touches no capture-kit or Swift file), r4187333233 (the lock-holding owner now registers its signal, and the queued remainder is the second issue above), r4188422578 (an uncounted fire-and-forget prewarm is intended), r4188482183 (the per-device settle now covers only registered sessions) and r4188785962 (stray asterisk gone). I read the code and your tests but did not run them. I did not list every daemon surface that reaches |
4ec271a to
1ee5cf6
Compare
…3220) A runner start builds, launches, and health-checks without holding the device session lock, so a teardown racing that window could kill the build and still let the start retry into a second concurrent `xcodebuild build-for-testing` on the same DerivedData directory. Every start now carries an admission token. Teardown of an in-flight start closes the token before killing prep processes, so later calls see an explicit retired verdict instead of retrying into the race; an open that only queued behind a settled fence is readmitted and proceeds normally. The prepare loop owns one token across its retry so the replacement build stays authorized, and a caller leaving on its own deadline marks the start retry-pending so teardown does not stop a build another owner still waits on. abortAll/stopAll fence all device starts for their duration instead of awaiting per-device session locks. The machinery lives in runner-artifact.ts beside the prep ledger it gates; no new static module edges, no host allowlist.
1ee5cf6 to
7ca8147
Compare
|
Rewritten at 7ca8147 on the smaller design you sketched — both rules are now structural, and the mechanism is smaller than the first round's. The shape. The retry loop owns one admission token: Fence follows start identity. A token minted by Waiter interest. Every caller whose cancellation can be observed counts before its first await: the token itself registers synchronously at capture, Size. The mechanism is ~280 added lines inside Live evidence. The iPhone 17 run did not force the fence window (the cold build landed while All inline threads are answered and resolved; the vacuous zero-signal assertion now registers the later start's build (4951) and asserts it stays running; env overrides are restored in |
… file (#3220) The fence work grew runner-command-retry.test.ts past its merge-base length, which the test-file size ratchet refuses. The two bad-cache recovery tests mirror the prepare artifact decision, so they move to runner-lifecycle-prepare-artifact.test.ts, which already carries that harness.
|
At 38527ee this is ready for human review. Both findings from the earlier review (#3239 (comment)) are fixed: each retry owner now holds its own start admission and passes it through options.startAdmission, and the separate admission module and the reroute and fence-lifetime code are gone. All 19 checks pass at this commit, and I know of no conflicts. Nothing else blocks merge. Not blocking, and you can take or leave these: (1) when a prepare caller's own deadline fires, the loop's finally in runner-lifecycle.ts#L96 drops the admission from the in-flight set while the detached build still runs, so a cancel from another request on the same device can stop the build the #2894 retry meant to join; the rule is that an admission leaves the in-flight set only when no start holding it is still running, so could the loop finish it after the last ensureRunnerSession promise settles? (2) The production-route control in runner-session-close-prep-fence.test.ts#L187 issues its retry before close enqueues, so the refusal comes from pendingTeardowns and not from start identity; a test that drives prepareLocalIosRunner through a real close and asserts one build plus a runnerStartRetired refusal would pin the case from the earlier review. (3) Each call now creates its own admission and the two owner paths are mutually exclusive, so an admission never holds more than one waiter; could the waiter set collapse to one owner signal, with the four two-waiter tests rewritten as two admissions on one device? On the simplicity question: the one-admission-per-owner design answers it, and the collapse in (3) is the only simplification left. I ran no tests; I judged the regression coverage by reading the pre-change route. Live refused-retry evidence is absent, and the deterministic exec-seam control stands in for it, as the earlier review allowed since the window could not be forced on an iPhone 17 simulator. I did not measure the eager-closure budget claim, which no longer matters with per-call admissions. Note (1) assumes the prepare request's signal is deregistered once its caller deadline answers, and I did not trace the daemon request scope to confirm that. On the earlier review threads, these still apply: none at P1 or P2, and none at lower priority. These are fixed at this head and can be resolved: #3239 (comment), #3239 (comment), #3239 (comment), #3239 (comment), #3239 (comment), #3239 (comment), #3239 (comment), #3239 (comment). These do not apply: #3239 (comment) (a prewarm with no signal or requestId has no caller who could cancel it, same as main's #3193 behavior), #3239 (comment) and #3239 (comment) (rebase artifacts; the diff against 37fa3e7 has no capture-kit or Swift file). |
|
* origin/main: (77 commits) fix(apple-runner): fence prep spawns behind a start-owned admission (callstack#3239) 0.21.22 test(apple): own the simctl settings plan tests in simctl-settings.test.ts (callstack#3244) fix(limrun): report the session device id in iOS settings refusals (callstack#3243) 0.21.21 feat(remote): add a host-allocated macos-app lease backend (callstack#3236) test(android): bound the screenshot write wait by wall time, not event-loop turns (callstack#3250) feat(recording): cap the touch overlay frame rate at the caller's --fps (callstack#3241) fix(ad-script): let .ad scripts carry scroll --until and wait capture flags (callstack#3197) (callstack#3234) feat(provider-webdriver): keyboard enter, dismiss, and status over WebDriver (callstack#3233) feat(selectors): match role= against snapshot kind with a node-scoped alias window (callstack#3232) fix(provider-webdriver): read field values, placeholders, secure fields, and checked state from page source (callstack#3231) feat(replay): accept --test-ime on test and replay so flow-owned Android opens opt into the test IME (callstack#3235) refactor(daemon): route daemon-level diagnostics through one scope helper (callstack#3242) docs(adr): correct ADR 0031 pointer event delivery evidence (callstack#3245) fix(ios): stop a tap's post-gesture lookup from recording an XCTest failure (callstack#3060) (callstack#3237) fix(recording): render the touch overlay at most 30 fps and inside the record request (callstack#3219) fix(daemon): keep an idle daemon alive only for retained leases (callstack#3227) fix(provider-webdriver): send an empty JSON object on bodyless POSTs (callstack#3230) Feat/maestro repeat while (callstack#3214) ...
Problem (#3220)
A runner start builds, launches, and health-checks without holding the device session lock for the whole sequence. A
closeracing that window kills the in-flightxcodebuild build-for-testing, and the start then answers the kill by retrying — spawning a second concurrent build on the same DerivedData directory while close waits for the lock the retry holds.Design
Every start carries an admission token scoped to that start (
RunnerStartAdmissioninrunner-artifact.ts, exported through therunner-xctestrun.tsbarrel). Per-device state is only a pending-teardown count and the set of in-flight tokens — the device map holds in-flight tokens and nothing else.finally. There is no device-global fence lifetime and no clear-after-teardown step.runnerStartRetiredError(reasondevice_teardown); a freshopenthat only queued behind a settled fence is readmitted and proceeds normally. Idle-stop and speculative-release do not fence — the reviewer's requested semantics.prepareLocalIosRunnermints a loop admission and hands it to eachensureRunnerSessionattempt throughoptions.startAdmission, so the loop's replacement build is the authorized continuation of the same start (and is never reopened by a settled fence).stopRunnerPrepProcessesWithoutLiveOwnerstops only builds whose token has no interested waiters and no pending retry. Every caller that can still cancel counts as an interested waiter, including requestId-only daemon owners (reserveRunnerStartOwnerInterest).abortAll/stopAllfence all device starts for their duration instead of awaiting per-device session locks (review P2: mid-phase lock holders no longer block the sweep).Layout
The machinery lives in
runner-artifact.tsbeside the prep ledger it gates. A separate static module was tried and empirically fails the eager-closure budget (a new static edge grows façade closures); co-location adds zero modules to any closure, removes both dynamic loaders, and drops the.fallowrc.jsonhost allowlist entry entirely.Evidence
runner-session-close-prep-fence.test.ts(close parked mid-prep-kill → retry refused with typed reason, exactly one build ever spawned; queued caller readmitted after settle; negative control that a plain post-teardown open still builds).runner-start-admission.test.ts(token/fence/readmit/loop semantics),runner-start-budget.test.ts(last-waiter kill, deadline ownership, teardown-then-late-cancel with a registered later build, per-device scoping, owner interest),runner-artifact-start-admission.test.ts(spawn-gate enforcement).close --shutdown→ cached reopen → close, all clean; at most onebuild-for-testingin flight at every sample; daemon and session logs show no admission-token noise or errors.pnpm check:affected --rungreen (incl. eager-closure budgets, layering, fallow, format, lint).Closes #3220