Skip to content

fix(ios-snapshot): prepare the AX bridge off the capture deadline - #2607

Merged
thymikee merged 8 commits into
mainfrom
fix/ios-smoke-lane-2491
Sep 16, 2026
Merged

thymikee merged 8 commits into
mainfrom
fix/ios-smoke-lane-2491

Conversation

@thymikee

@thymikee thymikee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

A cold host paid for the AX bridge inside whichever capture happened to ask first, so that capture spent its whole deadline in an Apple toolchain probe or a clang build and the lane lost the wait that was polling for a screen (#2491). Preparation is now a detached single-flight per runtime: the first capture that finds it running waits out a 2 s budget and is served by the XCTest runner, the build keeps running for whoever asks next, and a failure is answered as-is until a retry window measured from the failure expires. The budget is granted once per attempt, not per capture — a wait poll cycles every ~200 ms and a budget paid per poll would cost more than the build it is waiting for.

packages/platform-apple/src/detached-attempt.ts is the one seam for this and for the pending target discovery, which had already started to forget failures differently. Each owner declares its wait budget, wait grant, retry window and pending error, and keeps its own cache of a finished value, because only the owner knows when that value stops being valid. waitForDetachedAttempt is the matching implementation of that wait contract: one timer, the caller's abort listener and the stop listener, all released on every settling path, so a capture that stops waiting leaves neither a timer nor a listener behind. Preparation owns the AbortController whose signal goes into the deadline, and close() aborts it.

14 files, +1154/-100, of which 755 lines are tests.

Validation

Run at 0e1fd02, rebased on origin/main.

  • pnpm check:affected --run green: format:check, lint, typecheck, check:layering, check:fallow --base origin/main, build, and vitest related across the 263 files it selects. The packages/platform-apple unit suite is 696 passed.
  • Cold bridge (injected xcrun clang stall of 60 s, dedicated Apex CI Smoke simulator, XCTest runner warm, runner built from this tree): wait text answers in 1.4 s and the six captures after it in 0.6-1.4 s each, every one served by the runner while the bridge is preparing. ios.snapshot-source.acquire spends 2006 ms once and 0-1 ms per capture after it. Under the same injection main fails wait text after 60.7 s with wait_capture_stalled and readableCaptures: 0.
  • Shutdown with that build still in flight: the stalled xcrun child count goes from 1 to 0 within 2 s of daemon stop.
  • Guards are deletion-checked rather than asserted: removing stopWaiting.abort(), the stop listener, the clearTimeout, the caller-listener removal, or the already-aborted stop check each fails at least one test; the deadline case that could not fail was deleted instead of kept. The cold-bridge numbers above are from 58e61bf8dc; the two commits since change only listener cleanup on that wait path, which is what those deletion checks cover.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.58 MB 4.58 MB +1.7 kB
Package (unpacked) 4.58 MB 4.58 MB +1.7 kB
Package (download) 1.36 MB 1.36 MB +664 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.8 ms 25.9 ms +0.2 ms
CLI --help 76.2 ms 76.2 ms +0.1 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at caae0b0. Moving preparation off the capture deadline is the right fix, but the retry window and shutdown do not hold yet, and the reported timing does not match this commit.

The retry window counts from the start of the attempt, not from its failure. failedAgoMs = now() - entry.attempt.startedAtMs at preparation.ts:124. An attempt that fails after 60 s or more (a toolchain stall plus a failed build or the 150 s ceiling) is already past SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS when it fails, so the next capture starts another build at once, on exactly the cold hosts this PR targets. The retry test cannot catch it because the fake build fails at clock 0. Can the attempt record failedAtMs when it rejects, with a test that advances the clock during the build?

Every capture waits the full 2 s slice while the attempt is pending (preparation.ts:143), even after an earlier capture already waited it out. On a cold host a wait poll that normally cycles about every 200 ms gets about ten times fewer polls inside its timeout. Could the slice apply once per attempt?

Shutdown cannot stop an in-flight build. close() in adapter.ts:112 closes the manager, but preparation has no close and its deadline has no signal (preparation.ts:79). The old code ran preparation under the request deadline, which could abort it. Now xcrun or clang can run up to 120 s past close and can leave .cacheKey.<pid>.tmp and its lock behind. Can preparation own an AbortController whose signal goes into createSnapshotSourceDeadline, and expose a close() the adapter calls?

A design question: this is a second hand-written detached single-flight with a bounded wait and a typed pending error in this package, next to awaitDiscovery in snapshot-target.ts. The two already forget failures differently, and the fixes above would have to land twice. Could one helper (wait slice, pending error, retention, abort on close) serve both, so preparation.ts shrinks to the build call plus a retry policy? If you rejected that, what would have to change first?

The PR body reports the stalled-build wait text answering in 0.3 s, but at caae0b0 a capture made while the attempt is pending cannot return before the 2 s slice ends. Was that run on this commit? Please re-run the live cold-build case on the final head with per-capture timings, including daemon shutdown while the build is still running.

CI is green and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at caae0b0. Moving preparation off the capture deadline fits #2491, but a few points need a look.

The retry window is measured from the attempt's start (preparation.ts#L124), so a slow cold-host failure can start the next attempt at once. Polls cannot stack builds while one is pending, so the cost is back-to-back attempts, not one per poll. The retry test at preparation.test.ts#L65 fails its build instantly with the clock frozen, so it cannot tell start time from failure time. Is the window meant to start at the failure? If so, could you record the failure time and advance the clock inside the failing build in that test?

adapter.ts#L110 creates the detached deadline with signal: undefined, and close() only marks the source closed. A build can keep running for up to 120 s after shutdown, and its cache write can land after the source is gone. Could the preparation own an AbortController that close() aborts?

Every capture that finds the attempt pending waits the full 2 s budget before it gets preparing (preparation.ts#L143). Before this PR, later polls went straight to XCTest after the first timeout; now each poll during a cold build pays 2 s. Could the wait apply once per attempt, and later captures get preparing at once?

The preparing fallback warning still says the whole app generation moved to XCTest, and snapshot-route.test.ts#L324 asserts that text. With this PR only that one capture uses XCTest. Can the wording and the test say so?

Could this be smaller? An owned { promise, settledAt, controller } per runtime, reusing the route's existing pending-discovery pattern, would cover what the separate attempt-state record tracks. Nothing has to change first.

Could you link the output of the live A/B run from the description? Its 0.3 s figure does not match the 2 s wait budget in this diff. All checks pass, and there are no conflicts.

@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch from caae0b0 to cc8f11b Compare September 15, 2026 08:45
@thymikee

Copy link
Copy Markdown
Member Author

All five points addressed at cc8f11b4a9.

Retry window from the failure. preparation.ts no longer measures from the attempt's start; the attempt table records settledAtMs when the attempt stops being pending, and the window is compared against that. preparation.test.ts advances the clock inside the failing build — by the retry window plus a second — so a start-based window fails the assertion that follows it (fixture.builds stays 1 through the next capture, then goes to 2 only after a real window elapses from the failure).

Wait slice once per attempt. The grant is now declared: waitGrant: 'first-caller' for preparation, 'every-caller' for discovery, which keeps discovery's documented per-capture wait as it was. The first capture that finds an attempt running spends the budget; every later capture is told preparing at once. Live, the follow-on captures answer in 1.2–2.3 s instead of 2 s each, and the request log for one of them reads ios.snapshot-source.acquire 0. Unit side: detached-attempt.test.ts asserts the wait function is called once across two callers under first-caller and twice under every-caller.

Shutdown cannot stop an in-flight build. The preparation owns an AbortController whose signal is the deadline's signal, and adapter.close() calls preparation.close() before closing the manager — the build's run receives that signal, so the child dies and cache.ts's own catch/finally remove the .tmp entry and release the lock. Live: with a 60 s stall in force, one xcrun clang process was writing its cache entry before shutdown and none remained 2 s after. The unit test asserts the signal run received is un-aborted before close() and aborted after, and that the next capture then starts a new attempt.

Warning wording. The preparing fallback now reads "used XCTest for this capture while the bridge is still being prepared." and snapshot-route.test.ts asserts that text, with the comment naming why the app-generation sentence would claim a retirement that did not happen.

The design question — you were right, and nothing had to change first. Preparation and target discovery are the same shape and had already diverged on failure retention. packages/platform-apple/src/detached-attempt.ts now owns it: one attempt per key, detached from whoever started it, bounded wait, typed answer while it runs, retention window, and abort on close. awaitDiscovery is gone (git grep -n "awaitDiscovery" cc8f11b4a9 → no results) and with it the Map<string, Promise<SimulatorSnapshotTarget>> it raced against; discoveries is now the table binding. snapshot-target.ts declares its wait budget and pending error and keeps its own targets cache. Settled successes deliberately leave the table — preparation caches the binary because it is content-addressed and verified at rest, discovery re-checks process start — so the difference the two had drifted into is now a declared owner decision rather than two implementations. What stayed per-owner on purpose is the wait primitive: the deadline-typed sleep of the snapshot-source layer and the caller-signal sleep of target resolution answer to different abort semantics, and neither should be modelled on the other.

The 0.3 s figure was not a cold-preparation measurement, so it could not match the wait budget; the re-run is on the final head, with the stall in place and per-capture timings in the Validation section.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at cc8f11b, as a follow-up to the review at caae0b0. The five earlier points are addressed, and the author-reported cold-build run with an injected clang stall reaches the changed route. The detached-attempt helper is the smaller design the last review asked for.

One small regression is left. value() in detached-attempt.ts#L158 races attempt.settled against params.wait(waitMs) but never cancels the losing wait. When the attempt settles first, its timer and abort listener stay alive for the rest of the budget (up to 1.5 s for discovery, 2 s for preparation). The old awaitDiscovery cleared both in a finally, and a tight wait-poll loop can build up one pending timer and listener per capture. Can wait take a cleanup signal from a per-call AbortController that value() aborts in a finally, so waitForSnapshotSourceDelay and waitForDiscoveryAttempt both clear their timer and listener?

Attaching the request-log lines from the cold-build run (the ios.snapshot-source.acquire times, and no xcrun process left after stop) would make that evidence checkable.

Typecheck & Package fails with TS2353 on installArgs in packages/platform-android/src/__tests__/snapshot-helper-install.test.ts. This PR does not touch that file, and main fails the same way at 2cafab3 because #2618 removed the field while that test still sets it, so the failure is unrelated to this PR. A rebase will not clear it until main is fixed.

Next: the wait cleanup.

@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch from cc8f11b to 5391485 Compare September 15, 2026 10:17
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto aba54b4cc6 (#2600 and #2625 landed), head 5391485c1d; no conflicts, no rework — this PR does not touch the runner transport or the settings vocabulary.

  • pnpm check:affected --run green at this head: format:check, lint, typecheck, check:layering, check:fallow --base origin/main, build, and vitest related over the 262 files touched transitively.
  • Cold-bridge scenario re-run at this head, dedicated Apex CI Smoke simulator, warm runner, fresh bridge cache, stubbed xcrun stalling 60 s: wait text returned in 1.0 s and the six following captures in 0.5-0.9 s each, every one carrying bridge-preparation-pending with the XCTest fallback. daemon stop while that build was still in flight took the stub xcrun process count from 1 to 0 within 2 s.
  • The earlier main comparison still stands: the same wait fails at ~60.7 s with wait_capture_stalled and readableCaptures: 0.

Also unblocked: the red Typecheck & Package job was main's own snapshot-helper-install.test.ts fixture, fixed by #2625.

@thymikee

Copy link
Copy Markdown
Member Author

The first Coverage run at 5391485c1d failed on one unrelated test, and the job is re-run now:

FAIL  unit-core  src/daemon/handlers/__tests__/session-device-claims.test.ts
  > provider-owned open creates no host-local device claim from its selected owner
Error: ENOTEMPTY, Directory not empty: /tmp/agent-device-test-run-2610-.../agent-device-session-device-claim-...
  at session-device-claims.test.ts:118  (teardown: fs.rmSync(root, { recursive: true }))

1 failed / 10,819 passed. It is that file's temp-directory teardown racing a concurrent write, not a coverage threshold, and nothing in this PR touches device claims; Coverage is green on main at aba54b4cc6 and green at the pre-rebase head cc8f11b4a9. Flagging it rather than waving it off because the teardown itself is worth hardening in the file that owns claims.

@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch from 5391485 to 111e06f Compare September 15, 2026 13:04
@thymikee

Copy link
Copy Markdown
Member Author

Fixed at 111e06f562 (rebased onto e3cbc91a1a, which carries #2625's fix for the installArgs fixture — Typecheck & Package is green here now).

wait takes the per-call stop signal: value() creates an AbortController and aborts it in a finally, so the loser of the race releases its timer and listener instead of holding them for the rest of the budget. waitForDiscoveryAttempt resolves on it, and waitForSnapshotSourceDelay gained an optional stop that ends the sleep without spending the deadline it measures against. A client abort still rejects with the typed cancellation — the stop only lands after the race is decided, which is why the losing wait resolves rather than rejects and why the race keeps a handler of its own.

detached-attempt.test.ts injects a wait with no timer of its own, so only the stop can end it: it asserts the signal is aborted and the wait resolved once the attempt settles. Deleting the stopWaiting.abort() makes it fail with that assertion. snapshot-source/deadline.test.ts covers the three endings of the delay: stopped, stopped-before-start, and caller-aborted staying typed cancelled.

Request-log lines from the cold-build run at this head (fresh runner build, warm XCTest runner, stubbed xcrun stalling 60 s, first-caller grant):

13:03:01.908 ios.snapshot-source.acquire 2001  preparing: bridge-preparation-pending   (warm capture: spent the budget)
13:03:07.127 ios.snapshot-source.acquire    0   preparing: bridge-preparation-pending   ios_snapshot_route_fallback bridge-preparation-pending
13:03:07.689 ios.snapshot-source.acquire    0   ...
13:03:09.138 ios.snapshot-source.acquire    0   ...
13:03:10.557 ios.snapshot-source.acquire    0   ...
13:03:11.974 ios.snapshot-source.acquire    0   ...
13:03:13.399 ios.snapshot-source.acquire    0   ...
13:03:14.827 ios.snapshot-source.acquire    1   ...

Only the first capture pays; the six following snapshots take 0.4-0.7 s end to end, and wait text returns in 0.3 s. daemon stop with the build still in flight:

--- bridge build in flight:
    88670 /bin/bash /tmp/apex-hub/stubs/xcrun --sdk iphonesimulator clang ... -o ~/.agent-device/snapshot-source/.05e23ee5....tmp/snapshot-bridge
    stub xcrun processes: 1
--- after shutdown:
    stub xcrun processes: 0

pnpm check:affected --run green at this head (263 related test files).

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 111e06f, as a follow-up to the review at 5391485. value() now aborts a per-call stop in a finally, so the losing wait can release its timer and listener.

One gap is left: the discovery stop path has no test. In snapshot-target.ts#L90, could the onStop listener, the stop.removeEventListener call and the pre-aborted early return all be deleted with every test still green? The detached-attempt test uses its own fake wait, and deadline.test.ts covers only the preparation owner. If this path regresses, each discovery poll again holds a 1.5 s timer and a caller-signal listener. Can you add a test through createSimulatorSnapshotTargetResolver where a second caller joins a pending discovery that then settles, and assert with fake timers that no timer is pending and the caller's AbortSignal has no listener left?

All checks pass, including Smoke Tests, which exercises this path, and there are no conflicts. Next: the discovery stop-path test.

@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch 3 times, most recently from 111e06f to 6adf53c Compare September 15, 2026 14:15
@thymikee

Copy link
Copy Markdown
Member Author

Fixed at 6adf53c2a4 (rebased onto 5fb2860119).

The test is there, driven through createSimulatorSnapshotTargetResolver with the existing deferred-spawn fixture and fake timers: one caller starts the discovery, a second joins it while pending, the discovery settles, and the assertions are the two things a leaked wait actually costs.

await vi.advanceTimersByTimeAsync(0);
release();
await vi.advanceTimersByTimeAsync(0);
await Promise.all([first, second]);

expect(abortListeners(starting.signal)).toBe(0);
expect(abortListeners(joining.signal)).toBe(0);
expect(vi.getTimerCount()).toBe(0);

deadline.test.ts now measures the same two properties for the preparation owner instead of only asserting that the sleep came back early, and drops the already-aborted case.

Your reachability question is the interesting part, and I checked it line by line rather than writing a test to keep each one:

  • delete the stop.addEventListener('abort', onStop, …) registration → fails (timer pending, listener on both signals)
  • delete clearTimeout(timer) in that finishfails
  • delete stop.removeEventListener('abort', onStop) → green, and correctly so: the listener is { once: true } and value() always aborts that stop in its finally, so the removal can never observe anything
  • delete if (stop.aborted) return finish(resolve) → green, because value() creates the signal moments before calling, so it cannot already be aborted

The last two are dead rather than under-tested, so both waits now drop them and say why in the doc comment: the stop is owned and always aborted by the caller of the wait, while the caller's own signal outlives the wait and does need its listener removed — that removal is covered, and deleting it fails.

At this head: packages/platform-apple 689 tests across 82 files, pnpm check:affected --run green (263 related files), typecheck clean.

One thing worth flagging about the local gate: check:affected's mutation-model step is red on main independently of this PR — scripts/mutation/ownership.test.ts still claims gestures.test.ts indirectly owns scroll-edge-state, and that test's import closure no longer reaches the scroll runtime. It only trips changes that touch a lane file, so it stays mostly invisible. Reproduced at 5fb2860119, addressed in #2637.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 6adf53c, as a follow-up to the review at 111e06f. The discovery stop path now has a test through createSimulatorSnapshotTargetResolver that checks no timer and no abort listener are left, so a removed onStop listener or cleanup would fail it. The unreachable pre-aborted check is gone.

One small thing is left in the test helper. abortListeners() at snapshot-target.test.ts#L146 double-casts the signal to call listenerCount, which is typed for EventEmitter. getEventListeners(signal, 'abort').length from node:events is typed for EventTarget and needs no cast. Can the helper become that one call? It also removes any doubt about listenerCount on an AbortSignal on the Node 22.12 floor.

Smoke Tests are still running. They exercise the iOS snapshot route this PR changes, so a failure there needs its log read. There are no conflicts. Next: swap the cast for getEventListeners.

@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch from 6adf53c to 58e61bf Compare September 15, 2026 15:34
@thymikee

Copy link
Copy Markdown
Member Author

Simplified at 58e61bf8dc after the last round of findings.

The stop/cleanup questions all came back to the same shape existing twice: the discovery wait and the bridge-preparation wait each hand-rolled a timer, the caller's abort listener, a stop listener and the cleanup that keeps a capture from leaking a timer and a listener. waitForDetachedAttempt in detached-attempt.ts now implements the wait that value()'s contract already describes, and each owner passes in its own sleep length and its own cancellation error. Both owners' tests still catch every deletion of it (no stop listener → 2 failures, no clearTimeout → 3, no caller-listener removal → 1), so the invariant is enforced once instead of twice. disableGenerationFor said one thing about one call site, so the retirement rule is now read inline where the set is edited.

Live re-run at this head, cold bridge, one stalled xcrun: the source-acquire cost is paid once (2006 ms) and then 0-1 ms for every later capture; wait 1.4s and six captures 0.6-1.4s against a 60s stall; daemon stop 0.8s with the stalled xcrun going 1 → 0. pnpm check:affected --run green (263 files), typecheck clean, packages/platform-apple 693 tests.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 58e61bf, as a follow-up to the review at 6adf53c. One wait helper for discovery and the deadline is smaller, but it adds a listener leak.

NO_STOP at deadline.ts#L29 is one module-level AbortSignal that nothing aborts. waitForSnapshotSourceDelay passes stop ?? NO_STOP, and waitForDetachedAttempt adds a once abort listener to it that finish never removes. The bridge connect retry sleep (lifecycle.ts:187) and waitForSimulatorTurn (lifecycle.ts:302, on every bridge request) pass no stop, so each call leaves a listener that holds the timer and the caller's deadline signal. A small Node script showed 20 calls leave 20 listeners. Before this delta, a missing stop added none. Can stop become optional in waitForDetachedAttempt, with finish removing its listener and NO_STOP deleted? A deadline.test.ts case with no stop that expects no listener left would pin it; today every test there passes a stop.

The new comment at detached-attempt.ts#L72 says stop needs no cleanup because value() aborts it in a finally. deadline.ts does not go through value(), so the comment should change with the fix above.

snapshot-target.ts#L63 now rejects at once when the signal is already aborted, where the old discovery wait waited out waitMs. That looks better, but is it intended? A test with a pre-aborted signal that expects the abort reason without advancing timers would pin it.

All 18 checks pass and there are no conflicts. Next: remove the stop listener when the wait settles, with the no-stop test.

@thymikee

Copy link
Copy Markdown
Member Author

Pushed 14a2dea to fix the listener leak. waitForDetachedAttempt now takes an optional stop, adds its listener before the already-aborted check, and removes it in finish on every settle path. NO_STOP is gone, and the doc comment now states that contract. New tests cover a timeout settle and a pre-aborted caller signal (no timer or stop listener left), and a deadline sleep with no stop. The pre-aborted test fails if the stop listener is added after the aborted check. The pre-aborted discovery wait now rejects at once, and that is pinned too. pnpm check:affected --run passes on 14a2dea.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 14a2dea. The listener leak from the 58e61bf review is fixed: stop is optional, NO_STOP is gone, and finish removes both listeners on every settle path. Two small points are left.

In detached-attempt.ts#L96, only the caller signal gets an already-aborted check. If a caller passes a stop that is already aborted, its listener never fires and the wait sleeps the full waitMs. All three current callers create stop fresh in value(), so this cannot happen today. Could you add if (stop?.aborted) onStop(); after the caller check, or keep one sentence in the doc comment that says stop must be fresh?

The new case at deadline.test.ts#L52 says a delay with no stop leaves no listener behind, but it only checks the caller signal and the timer. 58e61bf already cleaned up both, so from reading that code the case would pass there too. The leak was on the shared NO_STOP signal, and the two new tests in detached-attempt.test.ts already pin it. Can this case be dropped or renamed?

On the earlier question: the immediate rejection for a pre-aborted signal is not new, because main had the same check. Nothing to change there.

Smoke Tests is still running on 14a2dea. This PR changes the iOS snapshot route that the lane exercises, so a green run there is the evidence #2491 needs. There are no conflicts. Next: a green Smoke Tests run. The two points above are small.

@thymikee

Copy link
Copy Markdown
Member Author

Smoke Tests has now failed on 14a2dea: id="automation-longpress" did not become visible after scrolling (job log). The same assertion failed on #2571, #2591, #2594 and #2600, so it is likely unrelated to this change. It also means this run does not yet show the lane green for #2491. The code verdict above does not change.

A cold host pays for the AX bridge inside the capture that happens to ask for it first, so the
first capture of a session spent its whole deadline in a toolchain probe or a clang build and the
lane lost the `wait` that was polling for a screen (#2491). Preparation is now a detached
single-flight per runtime: the first capture that finds it running waits out a short budget and is
served by the XCTest runner, the build keeps going for whoever asks next, and a failed attempt is
answered as-is until a retry window measured from the failure expires.

The grant is once per attempt rather than once per capture. A `wait` poll cycles every 200 ms or
so, and a budget paid per poll would cost a cold build more in captured polls than the build itself
costs, with every one of those captures ending up on the runner anyway.

Both this and the pending target discovery in `snapshot-target.ts` are the same shape — one attempt
per key, detached from whoever started it, a bounded wait, a typed answer while it runs — and they
had already started to forget failures differently. One seam owns that shape; each owner declares
its own wait budget, wait grant, retry window and pending error, and keeps its own cache of a
finished value, because only the owner knows when that value stops being valid.

The preparation carries the signal `close()` aborts. Once a capture has answered, no request owns
this attempt any more: without that, `xcrun` could keep running for two minutes past shutdown and
its cache write would land in a directory belonging to a source that is gone.
`value()` raced the attempt against the caller's wait and left the loser running. A capture answered
by the attempt still held its timer and abort listener until the budget ran out — 1.5 s for discovery,
2 s for preparation — so a poll loop could stack one pending timer and one listener per capture. The
`awaitDiscovery` it replaced cleared both in a `finally`.

`wait` now takes a per-call stop signal that `value()` aborts in a `finally`, and both owners release
their timer and listener on it: `waitForDiscoveryAttempt` resolves, and `waitForSnapshotSourceDelay`
grows an optional `stop` that ends the sleep without spending the deadline it is measured against.
Waiting inside the caller's own deadline still matters, so a client abort keeps rejecting with the
typed cancellation; the stop only ever lands after the race is already decided, which is why the
losing wait resolves rather than rejecting, and why the race keeps a handler of its own.
… settles

The stop path inside `waitForDiscoveryAttempt` had no test: the listener registration, the listener
removal and the already-aborted check could each be deleted with every suite green.

`snapshot-target.test.ts` now runs a deferred spawn through `createSimulatorSnapshotTargetResolver`,
lets a second caller join the pending discovery, settles it, and asserts on the two things a leaked
wait costs — a timer still pending and a listener still on the caller's `AbortSignal` — with fake
timers, so neither is a timing race. `deadline.test.ts` measures the same two properties for the
preparation owner and drops its already-aborted case.

That case was one of the things the review asked about, and it is unreachable rather than untested:
`value()` creates the stop moments before calling and aborts it in a `finally`, so the check can never
be true and the stop's `{ once: true }` listener is always released by that abort. Both waits now
drop the check and the redundant removal, and keep the cleanup that does matter, which is the
caller's own signal: it outlives the wait, and forgetting it would leave a listener per capture.
…rement rule where it applies

Three reviewers in a row had to be satisfied about the same promise plumbing because it existed twice:
the discovery wait and the bridge-preparation wait each hand-rolled the timer, the caller's abort
listener, the stop listener and the cleanup that keeps a leak from costing a timer and a listener per
capture. `waitForDetachedAttempt` now implements the wait that `value()`'s contract describes, and both
owners hand it their own sleep length and their own cancellation error — which also means the invariant
is deleted-and-caught in one place instead of two.

`disableGenerationFor` said one thing about one call site, so the rule moves inline next to the set it
edits: a failed bridge retires the app generation, a bridge that is merely still building does not.
waitForDetachedAttempt added an abort listener to stop but only ever removed
the one on signal, so every call with no explicit stop leaked one listener
onto the module-level NO_STOP signal per production caller (lifecycle.ts's
bridge-connect retry sleep and every bridge request's waitForSimulatorTurn).
Make stop optional, remove its listener on every settle path, and delete
NO_STOP now that the wait tolerates a missing stop directly.
…case that cannot fail

waitForDetachedAttempt only checked the caller signal for an already-aborted
case; a pre-aborted `stop` would sit until waitMs expired instead of ending
at once. Add the same already-aborted check for `stop`, guarded so the first
settle wins if both signals happen to be aborted together.

deadline.test.ts's "a delay with no stop leaves no listener behind" case only
exercised the caller signal and the timer, both of which the pre-fix code
already cleaned up correctly, so it could never fail. Delete it; the
detached-attempt.test.ts cases pin the actual leak.
Rebasing onto main picked up the ban on node:os in tests (#2629's mkdtempForTest
routing), and this test's own scratch dir predates that change. Route it through
the same helper so the branch's own new test stays lint-clean; behavior is
unchanged, only where the temp directory comes from.
@thymikee
thymikee force-pushed the fix/ios-smoke-lane-2491 branch from 14a2dea to 0e1fd02 Compare September 15, 2026 18:15
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main as 0e1fd02, with both points from the 14a2dea review fixed. waitForDetachedAttempt now ends at once on an already-aborted stop, and a new test would time out without that line. The deadline case that could not fail is gone. The rebase conflict in adapter.test.ts keeps this PR's test and uses main's mkdtempForTest. preparation.test.ts also moved to mkdtempForTest, because main's node:os ban now covers it. pnpm check:affected --run passes on 0e1fd02. The evidence #2491 still needs is a green Smoke Tests run on this head.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 0e1fd02, as a follow-up to the review at 14a2dea. Both points are fixed. waitForDetachedAttempt now ends at once on an already-aborted stop and clears the timer and both listeners on that path, and the new test would hang on the 60 s timer without that line. The deadline case that could not fail is gone, and the stop-leak tests in detached-attempt.test.ts still cover the leak. The adapter.test.ts conflict resolution and the mkdtempForTest change only move test scratch to the shared helper.

There are no conflicts. One Smoke Tests job is still running on 0e1fd02. This PR changes the iOS snapshot route that the lane exercises, so a failure there would not be unrelated. Next: a green Smoke Tests run on 0e1fd02, which is the evidence #2491 needs.

@thymikee

Copy link
Copy Markdown
Member Author

The Smoke Tests failure on 0e1fd02 is the cold-host XCTest fallback that main already has, not this change.

The lane's assertWebViewRemoteContent got Simulator AX snapshot unavailable (target-resolution-failed); used XCTest for an unverified app generation. and its regex accepts only remote-content-boundary or circuit-disabled — so the capture never reached the bridge, and the route served XCTest for a target it had not resolved. That path is main's: TARGET_DISCOVERY_WAIT_MS = 1_500, the simulator-target-discovery-pending rejection and the route's target-resolution-failed fallback with unknownGenerationResidue() are all unchanged here (git diff origin/main...HEAD -- packages/platform-apple/src/snapshot-route.ts touches only the source-preparing wording and the retirement rule). Main's comment on that constant already says the probe takes "several seconds on a loaded CI host", and a shared runner is what the lane gets.

main shows the same mechanism, one assertion over: runs 34965072036 and 34962062218 both failed today with regular depth-1 snapshot must not carry XCTest tree quality metadata, which is an unexpected XCTest fallback surfacing as tree-quality metadata instead of as a missing WebView disclosure.

Re-run requested on the failed job. One weakness worth its own issue rather than a change here: target-resolution-failed covers both a warming target and a genuinely failed resolution, so the disclosure can name the wrong reason for the fallback.

@thymikee

Copy link
Copy Markdown
Member Author

Rerun of the failed job is green: all four Smoke Tests jobs pass on 0e1fd02, and every other check is green with the branch mergeable. The body now carries the numbers from this head (check:affected green over 263 files, 696 packages/platform-apple tests) instead of the ones from 111e06f.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up at 0e1fd02: the evidence gap is closed. All four Smoke Tests jobs pass on this head after the rerun. The code verdict from the review above stands, and there are no conflicts. Next: human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee
thymikee merged commit f2c435c into main Sep 16, 2026
18 of 19 checks passed
@thymikee
thymikee deleted the fix/ios-smoke-lane-2491 branch September 16, 2026 09:19
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-16 09:19 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