Skip to content

fix(apple-runner): recovery decisions read typed rules, and a deadline gets a verdict - #2600

Merged
thymikee merged 7 commits into
mainfrom
fix/apple-runner-typed-recovery-2524
Sep 15, 2026
Merged

thymikee merged 7 commits into
mainfrom
fix/apple-runner-typed-recovery-2524

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Two private message-substring chains decided runner recovery outside RUNNER_ERROR_RULES. A command that ran out its connection deadline arrives in a shape whose message matches neither timeout nor timed out, so executeRunnerCommand rethrew without restarting a session whose runner never received the command, and shouldRetryPrepareRunnerHealthFailure ORed in a connect-retry default that retried past a rule which had just denied it. Both chains are deleted.

fetchWithTimeout reports its own expiry as COMMAND_FAILED carrying timeoutMs — the shape isCommandTimeoutError already reads. Until then the bare AbortSignal.timeout rejection reached asAppError with no details, so no rule could see it. Only a rejection carrying that signal's own reason is relabelled; a refused connection or a canceled request keeps its own error.

RunnerErrorMatch.details is a predicate, which is what makes a recorded deadline and the readiness-preflight marker expressible as evidence. The table gains restartAfterReadinessPreflight and artifactSuspect; the cache wipe reads the latter, so derived data is destroyed only when the runner refused or never answered on every route — a boot that exited early says false although its message also reads as a refused connection. A bare deadline earns no replay verdict: the same recorded budget covers a wait in the connect loop and a fetch that died after the command was written.

Closes #2524. 6 files, one module group. Two notes: a prepare deadline no longer wipes derived data (deliberate — the cost is a clean build); device_busy_connecting: false is a table verdict only, since no production message carries that text today.

Validation

Tested at 8b250ca757. pnpm check:affected --run passed: 367 test files. The 6 new/changed classification, restart and wipe tests were run red first; removing artifactSuspect: false from the boot-denial rule turns two of them red.

Live: open "Agent Device Tester" --relaunch on iPhone 16 Pro (4.7s, runner adopted), then a forced prepare ios-runner --timeout 4000 deadline — 9 connect retries, one session invalidation, typed prepare_deadline_expired, and no DOMException/TimeoutError in the request diagnostics or runner.log. Sessions closed, daemons stopped --clean, app uninstalled, no stale claims.

runner-contract.ts is 553 lines: the table must stay there (eager-closure-budgets is no-growth), so it grows rather than splitting.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.56 MB 4.56 MB +425 B
Package (unpacked) 4.56 MB 4.56 MB +425 B
Package (download) 1.35 MB 1.35 MB +170 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.2 ms 27.9 ms -0.3 ms
CLI --help 78.8 ms 76.8 ms -2.0 ms

@thymikee

Copy link
Copy Markdown
Member Author

At 8b250ca the message chains are gone from the recovery decisions, a user abort is still rethrown unchanged, and a deadline after send is not replayed. I traced those through runner-transport.ts and executeRunnerCommand.

The tests do not show the old routing was wrong. The edited retry test now uses 'xcrun timed out after 45000ms', which the old timed out check also matched, and the early-exit no-wipe case already had connectRetry: false on main. So both pass on the old code. Please add one route-level test whose outcome changes, for example a readiness preflight error with timeoutMs and no timeout wording.

The cache wipe is narrower than before. On main, shouldRecoverBadCachedRunnerArtifact wiped a restored artifact on retryable, connect-retry or prepare-timeout errors. Now only runner_connect_refused and runner_endpoint_probe_exhausted set artifactSuspect. The PR body says the prepare-deadline part is deliberate. What recovers a restored artifact whose runner hangs past the deadline on every attempt? Please add a test for that case, or a CHANGELOG line if failing there is the intended behavior. Small nit: hasReadinessPreflightFailure in runner-contract.ts repeats isRunnerReadinessPreflightError in runner-lifecycle.ts.

Coverage fails because of this change: test-file-size-ratchet rejects runner-command-retry.test.ts growing from 1280 to 1323 lines, so the new tests need a split file. Smoke Tests fail in the native testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt, the same alert test that failed on #2592 and #2596 today; this diff touches no Swift, so that one is likely unrelated. The live prepare ios-runner deadline run is noted, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Two follow-ups while CI was running.

Coverage was red for a reason of mine. scripts/__tests__/test-file-size-ratchet.test.ts refuses growth in a test file already over the tripwire, and runner-command-retry.test.ts is one: 1280 lines at the merge-base, 1323 after my four readiness-preflight cases. ea981ab57e gives them the file their question deserves, runner-lifecycle-readiness-preflight.test.ts — what does executeRunnerCommand do with a runner that refused before the command was written — and carries the pre-existing pair of those tests along with them rather than leaving one half of a decision in each file. The aggregation is back to 1231 lines, below what it was before this PR, and makeRunnerSession now comes from the fixtures module that already owns that fabrication instead of a local copy of it.

Smoke Tests is a flake in the alert-observation fixture, not this change. The job failed on testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt (RunnerTests+AlertObservationTests.swift:75) with ("First actions: 0; replacement actions: 0") is not equal to ("First actions: 1; replacement actions: 0"), and this PR touches no Swift and no alert path. #2595 failed a few hours earlier on the neighbouring testAlertAcceptDoesNotActivateAReplacementWithASharedButton, passed a rerun unchanged, and merged. Nothing here was changed to make the runner suite pass; the rerun is what should settle it.

Local verification: pnpm check:affected --run passed at ea981ab57e (368 test files), and the size-ratchet and eager-closure suites pass locally (615 assertions) — neither is inside check:affected's selection, which is why the ratchet only surfaced on CI.

@thymikee

Copy link
Copy Markdown
Member Author

ea981ab moves the readiness-preflight tests into runner-lifecycle-readiness-preflight.test.ts with only the shared session fixture swapped in, which fixes the size ratchet. The two points from the last review are still open: a route-level test whose outcome differs from the old message-based routing, and what recovers a restored runner artifact that hangs past the prepare deadline on every attempt (a test, or a CHANGELOG line if failing there is intended). Smoke Tests are rerunning, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Pushed 1b0a1a6f9a. All three addressed.

1. Route-level test that the old wording check fails. runner-lifecycle-readiness-preflight.test.ts now has "a readiness preflight deadline with no timeout wording still restarts the session": the error is COMMAND_FAILED with runnerReadinessPreflightFailed: true and timeoutMs: 45_000, and a message that never mentions time (xcrun simctl spawn did not answer). It asserts the restart and the replay onto the fresh session.

Verified it actually pins the routing, not just the outcome — restored the deleted messageIncludesAll: ['timed out'] match on that rule and re-ran the file:

x a readiness preflight deadline with no timeout wording still restarts the session
Tests  1 failed | 4 passed (5)

One test red, the four existing cases green, which is the routing difference nothing else on the branch could show. Restored afterwards (6/6 green across both lifecycle files).

2. Restored artifact that hangs past the prepare deadline on every attempt. Nothing rebuilds it, and that is now written down twice rather than left to the reader:

  • runner-lifecycle-prepare-artifact.test.ts pins what does happen: the artifact survives (markRunnerXctestrunArtifactBadForRun never called), the session is invalidated (prepare_runner_health_failed), and the caller hears the deadline. Same mutation check: widening the wipe back to any prepare timeout — the old isPrepareHealthTimeout path — turns that test red.
  • A CHANGELOG.md entry under Unreleased says it in the same terms, including how you do get out: a failure that indicts the artifact (connection refused, no route answered), or the runner cache cleared by hand.

3. Duplication. isRunnerReadinessPreflightError is gone from runner-lifecycle.ts. The contract module now exports isRunnerReadinessPreflightFailure(error), which reads the marker through the same hasReadinessPreflightFailure the table matches on, so the marker rule and the route cannot drift.

Gate: pnpm check:affected --run green locally, 409 apple-runner tests across 40 files, plus test-file-size-ratchet and eager-closure-budgets run explicitly since check:affected does not always select them.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed the ea981ab..1b0a1a6 delta.

The prepare-artifact test does not guard the wording check this PR removes. It builds the deadline as AppError('COMMAND_FAILED', 'xcrun simctl spawn did not answer', { cmd, timeoutMs }), but the real prepare deadline is 'Runner connection deadline exceeded' (runner-startup-transport.ts:165), and that message contains the word the removed isPrepareHealthTimeout matched. The test at runner-lifecycle-prepare-artifact.test.ts:90 fails on the old code only because shouldRetryRunnerConnectError defaulted to true. If a message check comes back into the artifact-wipe decision, this test stays green while real prepare deadlines wipe restored artifacts again. Can the fixture use the real shape, new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { timeoutMs: 45_000 })?

Only mocked unit tests cover the two changed routes. Before merge, please add a live iOS run that shows a readiness-preflight deadline restarting the runner and replaying the command, and a restored artifact that survives a prepare deadline.

CI was still running at review time, with no failures to attribute.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 1b0a1a6. Both points from the last review are covered: the new readiness-preflight test fails on the old message-based routing, and the CHANGELOG now says a restored artifact that hangs past the deadline is kept and the deadline is reported.

One question on the CHANGELOG wording at CHANGELOG.md#L5. In Node, an AbortSignal.timeout() rejection says "The operation was aborted due to timeout", which the old wording check already matched. On the preflight route, waitForRunner catches each endpoint's fetchWithTimeout error and rethrows "Runner did not accept connection", so the new deadline wrap does not reach the lifecycle there. The case the test really closes is a preflight failure with timeoutMs and no timeout wording. Could the entry and the test comment say that, instead of saying the fetchWithTimeout change causes the restart?

All checks pass, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Both asks are addressed, and the second one changed the shape of the first, so they read best together. b98894b850.

The fixture now carries the error a real deadline produces

You were right, and my first pass was still wrong. Tracing each decision to its producer:

  • Artifact/prepare wipe (runner-lifecycle.ts:142) only ever sees what runPrepareHealthCheck raises, and that is readPreparePhaseTimeoutMsprepare ios-runner timed out with { phase, reason: 'prepare_deadline_expired' } (runner-lifecycle.ts:484). Runner connection deadline exceeded can never reach that decision: waitForRunner rewrites every connect failure into Runner did not accept connection before the lifecycle sees it. The test now builds a real Deadline, spends it inside the mocked boot with vi.setSystemTime, and lets production raise the failure — there is no hand-written message left in it.
  • Preflight keeps Runner command deadline exceeded + runnerReadinessPreflightFailed, because that one is the reachable shape from fetchWithTimeout / postUsbmuxRunnerCommand.

Proof the fixture bites: putting the deleted wording match back (message.includes('timed out')) in shouldRecoverBadCachedRunnerArtifact sends that test down the rebuild path — it fails with Expected: "runner_session" / Received: "runner_rebuild". The old fake message passed either way, which is the hole you spotted.

Live run

Own simulator (iPhone 17, iOS 26.2, created for this and deleted after), own --state-dir, pnpm build + pnpm clean:daemon, Safari as the app surface. Fault injected from outside with kill -STOP on the runner process for my UDID only.

  1. Restored artifact + runner that never connects. Request f49312ff848bc1dd: runner_xctestrun_cache action:"clean" reason:"Runner did not accept connection" (that is the runner_connect_refused verdict doing its job), then the rebuild spends the budget and the command dies with prepare ios-runner timed out, phase: runner_rebuild, reason: prepare_deadline_expired. Same string the unit test now uses, observed on a device. The CHANGELOG entry is rewritten around this: expiry-shaped failures stopped wiping, a runner that refuses still does, because that is what the rule is for.
  2. Frozen runner + mutation → session restart and replay. Three requests, three taps that succeeded (27.9s, 18.0s, 8.5s) served by a new runner instance each time (pid 68528140572583656742, new app container each time, AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=tap ok=1 in runner.log). Request e74f1fc8cd1c371c records the route: ios_runner_readiness_preflight 1001ms (reason: healthy_mutation_stale, probe budget timeoutMs: 1000, error: "Runner did not accept connection") → ios_runner_session_invalidated reason: runner_connect_failed_before_command_send → fresh-session preflight (reason: startup, timeoutMs: 45000) → ios_runner_command_send 914ms ok.
  3. Post-send contrast, same injection. Request fe1bdaa07ca4e116 has runnerReadinessPreflightSkipReason: "recent_healthy_mutation" (ageMs 4362), so the command itself was sent to a frozen runner and expired: Runner command deadline exceeded, timeoutMs: 45000, no restart — correct, a command already written is not replayable. Worth knowing when reproducing: inside that skip window the preflight never runs, so nothing restarts.

What I could not show live, stated plainly

Run 2 is the connect route (restartBeforeSend), and that route exists on main too. It is not evidence for the route this PR adds, and I don't want it filed as such.

runner_readiness_preflight_deadline fires only when the probe reports a deadline — connect succeeds, no answer within its budget, fetchWithTimeout expires with timeoutMs, marker attached. Every injection available to me from outside makes the frozen runner answer the probe as a refusal (connect-shaped, restartBeforeSend wins the branch order) or answer it immediately: a probe queued behind an in-flight wait returned in 2ms and 12ms, so the runner's accept loop is not blocked by XCTest main-thread work. Forcing the hanging-fetch variant needs either privileged traffic shaping (pfctl/dnctl) or a test-only fault hook inside the runner that stalls a readiness probe. I did not do either: the first needs sudo on this machine, the second adds a runner surface for a verification run, and this PR is TypeScript-only.

So that route rests on: the typed producer chain (fetchWithTimeoutmarkRunnerReadinessPreflightError) exercised through the real modules in the route test, the golden table, and the mutant above. If you want device evidence for it anyway, say which you'd accept — a runner-side stall hook behind a test-only env var, or a pfctl rule I hand you to run — and I'll produce it on this branch.

Session closed, daemon stopped with --clean, simulator and state dir deleted. Shared runner cache left as found (cache-dadd2322282ff7c5, valid).

@thymikee

Copy link
Copy Markdown
Member Author

CI note so the missing smoke row on the current head is not read as a gap: b98894b850 and bcda1a48ab touch only packages/platform-apple/src/runner/__tests__/** and CHANGELOG.md, both in .github/workflows/ios.yml's paths-ignore, so the iOS lane does not start for them. The lane did run green on 1b0a1a6f9a (run 34878947938), which carries the production commit 8b250ca757 — so the code being merged has a green device lane, plus the hand-run device evidence above.

@thymikee
thymikee force-pushed the fix/apple-runner-typed-recovery-2524 branch from b98894b to 553610f Compare September 14, 2026 21:00
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main (23fd3c94f0, 0.21.3) — CHANGELOG.md was the only conflict, and it is an ordering collision with the BrowserStack entries, not a semantic one. Head is now 553610f16a.

runner-contract.ts, runner-lifecycle.ts, and runner-transport.ts are byte-identical to the tree the green iOS lane tested at 1b0a1a6f9a (git diff b98894b850 HEAD -- <those three paths> is empty), so the device evidence above and that lane still describe the code being merged.

Local pnpm check:affected --run passes on the rebased branch. One note on the first attempt: runner-client.test.ts :: ensureXctestrunArtifact aborts only the disconnected request build… timed out at its 5s budget under host contention and passed in 52ms in isolation; the clean full run after that is the one reported here.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 553610f. The CHANGELOG entry and the test comment now describe the route the test closes, the new tests fail on the old message-based routing, and the three runner files are unchanged from the tree tested at 1b0a1a6. One evidence gap is left.

runner_readiness_preflight_deadline at runner-contract.ts#L251, used at runner-lifecycle.ts#L312-L320, is the new decision #2524 asks for: a connection deadline gets a restart verdict. Only a mocked unit test covers it. The live run in the comments reached the existing runner_connect_refused / restartBeforeSend route ("Runner did not accept connection"), which main already has, as you noted there. Could you force the new shape on a device, a command that dies inside the readiness preflight with a bare deadline, for example with the stall hook or the pfctl rule you proposed? If a device run is not practical, please say why the mocked test is enough for this route.

Smoke Tests failed in the fixture E2E: the long-press target did not become visible after scrolling, after all 94 runner XCTests passed. This PR does not touch that scroll and selector path, so the failure is likely unrelated. There are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Answered at 5c4a3eb34b. The device run you asked for was already the strongest evidence I had, and it says the shape the rule required is the one the preflight rarely produces — so the rule changed instead of the fixture.

What the wedge produced on a device. Own simulator, own state dir, warm Safari session, kill -STOP on the runner process, then a tap:

  • the preflight failed with "Runner did not accept connection", and the request was invalidated with runner_connect_failed_before_command_send (request e74f1fc8cd1c371c);
  • a second command whose preflight was skipped by a recent healthy mutation ended post-send with Runner command deadline exceeded + timeoutMs: 45000, and no restart — which is correct there, since the command had already been written (request fe1bdaa07ca4e116).

So timeoutMs at the top level and the preflight marker are not twins. A preflight hands back whatever its connect loop ended with — a refusal, an exhausted probe, a killed simctl fallback, a post that ran out of budget — and the budget-carrying one is one of four, reached by timing rather than by cause.

The rule now keys on the marker alone (runner_readiness_preflight_failed). runRunnerReadinessPreflight's catch puts that marker on whatever it was waiting on, and it is the only fact that says the command was never written, which is what makes the replay safe. hasCommandDeadline and hasReadinessPreflightDeadline are gone; shouldRestartAfterReadinessPreflightError reduced to the table lookup and is gone; isRunnerReadinessPreflightFailure lost its only consumer and went with it. connectRetry dropped out of the verdicts as well: the marker is applied after the connect loop has returned, so no loop can consult it.

Why the mocked test is enough now when it was not before. It no longer asserts a message, because the message is no longer part of the rule. What a device can contribute to this route is which shapes occur, and that is what the run above recorded; the verdict itself is a table walk over (code, message, details) with nothing device-side left to influence it. The tests still discriminate the claim rather than restating it:

mutation goes red
rule removed from RUNNER_ERROR_RULES the preflight marker alone decides the restart; all three lifecycle restart tests
rule requires a recorded timeoutMs again the preflight marker alone decides the restart; mutating commands restart stale sessions when readiness preflight fails before command send; a readiness preflight refusal restarts the session like any other preflight failure

Row two is the old rule, and it is red on the refused and fetch failed shapes — those are the live shapes the run produced, which is the gap you pointed at.

Tests. the preflight marker alone decides the restart covers the killed simctl fallback and a refusal, each with and without the marker. The lifecycle file keeps the budget-carrying route, keeps fetch failed, gains a readiness preflight refusal restarts the session like any other preflight failure (the shape no message check could have been written for) and a failed readiness probe without the marker does not restart the session, which bounds the change. The duplicate xcrun timed out lifecycle test is gone; that shape is now asserted where it is decided, at the rule.

CHANGELOG corrected: it claimed a preflight that runs out of time says "Runner command deadline exceeded" and that the rule reads the marker and the recorded deadline. Both files changed, so the iOS lane runs this head; pnpm check:affected --run, typecheck, lint, check:layering and check:fallow pass locally. On the smoke failure: same fixture long-press scroll that failed and then passed on a rerun in #2599.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 5c4a3eb, as a follow-up to the review at 553610f. The evidence gap from that review is closed. The verdict now depends only on the preflight marker, not on the error's shape, so the runner-session tests that prove the preflight catch applies the marker and the lifecycle tests that prove runAppleRunnerCommand routes on it cover this route. One problem is left.

A canceled command on a ready session can restart a healthy runner. A command on a READY session still runs the readiness preflight (runner-contract.ts#L237). If the caller's AbortSignal fires during that preflight, the startup transport throws createRequestCanceledError(), and the preflight catch marks it runnerReadinessPreflightFailed. The cancellation guard in executeRunnerCommand checks only !session.ready. assertRunnerRequestActive catches requestId cancellation but not a signal abort, and interactor.ts passes a signal for read-only commands and wait polls. The marker-only rule then sets restartAfterReadinessPreflight=true and restarts the runner, where main rethrows the canceled error. Could hasReadinessPreflightFailure also require details.reason !== REQUEST_CANCELED_REASON, or could executeRunnerCommand rethrow on isRequestCanceledError(appErr) || signal?.aborted before the restart branches? A lifecycle test that rejects a ready session with a marked createRequestCanceledError() and asserts no invalidateRunnerSession call would pin it.

A smaller question: a marked preflight failure that is not transport-shaped now costs one session restart before it surfaces. The per-request recycle budget bounds it and the CHANGELOG describes it. Is that the intended trade-off?

All 18 checks pass at 5c4a3eb, and there are no conflicts. The next step is the cancellation guard and its test.

thymikee and others added 7 commits September 15, 2026 10:11
…e gets a verdict

Two private message-substring chains decided runner recovery outside
RUNNER_ERROR_RULES, and both got the common case wrong. A command that ran out its
connection deadline arrives in a shape whose message matches neither 'timeout' nor
'timed out', so executeRunnerCommand rethrew without restarting a session whose
runner never received the command. And shouldRetryPrepareRunnerHealthFailure ORed in
shouldRetryRunnerConnectError, whose ?? true default let the connect loop retry past a
rule that had just denied it.

fetchWithTimeout now reports its own expiry as a COMMAND_FAILED carrying timeoutMs,
the shape isCommandTimeoutError already understands. Until then the bare
AbortSignal.timeout rejection reached asAppError with no details at all, so neither a
rule nor a timeout predicate could see it and the message chains were the only thing
catching it. Only a rejection carrying that signal's own reason is relabelled: a
refused connection or a canceled request keeps the error it actually failed with.

RunnerErrorMatch.details is a predicate rather than a closed union, which is what
makes a recorded deadline and the readiness-preflight marker expressible as evidence,
and the table gains two axes: restartAfterReadinessPreflight, and artifactSuspect for
the rules that say the restored xctestrun itself is at fault. The cache wipe reads
that axis, so derived data is destroyed only when the runner refused or never answered
on every route, and a boot that exited early says false even though its message also
reads as a refused connection. Both chains are deleted; what depends on runtime state
(a cached artifact being present, the request being canceled) stays in
runner-lifecycle.ts.

A bare deadline earns no replay verdict on purpose: the same recorded budget covers a
wait inside the connect loop, where waiting is right, and a fetch that died after the
command was written, where replaying it is not.
`runner-command-retry.test.ts` sits above the test-size tripwire and may not grow, and the
four preflight verdicts did not belong in a retry aggregation anyway: they answer one
question about what `executeRunnerCommand` does with a runner that refused before the
command was written. `makeRunnerSession` comes from the fixtures module that already owns
it.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
… not see

The route-level case the review asked for: a readiness preflight failure that records the
budget it ran out, carries the preflight marker, and says nothing about time in its
message. The table restarts the session and replays; the deleted substring chain rethrew.
Restoring that message check turns this one test red and leaves the rest of the file green,
which is the difference the earlier cases could not show.

The other half is the question the narrower wipe raises: a restored artifact whose runner
never answers past its deadline on every attempt. It is not rebuilt. `runner-lifecycle-prepare-artifact.test.ts`
pins what does happen — the artifact survives, the session is invalidated, the deadline is
reported — and it goes red if the wipe goes back to firing on any prepare timeout. The same
answer is now in the changelog, because "nothing rebuilds it" is a behavior someone will hit.

`isRunnerReadinessPreflightError` moves into the contract module it was duplicating.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
Both fixtures invented wording, which is the mistake the review caught: the case they are
supposed to pin is a specific producer's error, and a message nobody emits cannot show that a
deleted message check used to fire on it.

`ensureRunnerAttemptCanStart` reports an exhausted startup attempt as "Runner connection deadline
exceeded" with the budget in details — the word the deleted `isPrepareHealthTimeout` matched. With
that message the test fails when a wording check goes back into the wipe decision, and passes with
the invented "xcrun simctl spawn did not answer" against the same mutant, which is the hole the
fixture used to leave. `fetchWithTimeout` reports its own expiry as "Runner command deadline
exceeded", and the preflight marker rides along on the way out of the preflight catch; neither
deleted message chain looked for "deadline", so the route-level case is now built from that shape.

The changelog said the `fetchWithTimeout` wrap reaches the lifecycle on the preflight route. It does
not: `waitForRunner` turns each endpoint failure into "Runner did not accept connection". The
reachable case is the direct post on the simulator and usbmux paths, which is what the entry says
now.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
The rule asked for two facts: the marker the readiness preflight puts on its way out, and a
recorded budget in the same details. A device run says those two rarely arrive together. Wedging a
warm runner (`kill -STOP`, then a `tap`) produced "Runner did not accept connection" and invalidated
with `runner_connect_failed_before_command_send`; the budget-carrying shape appeared only post-send,
where replaying is not safe and no restart is wanted. A preflight reaches the caller in whatever
shape its connect loop ended with — a refusal, an exhausted probe, a killed `simctl` fallback, a post
that ran out of its budget — and only the last of those carries `timeoutMs` at the top level.

The marker is the fact the decision needs, so it is now the only one asked for. `hasCommandDeadline`
and `hasReadinessPreflightDeadline` go with it, `shouldRestartAfterReadinessPreflightError` reduces
to the table lookup and is gone, and `isRunnerReadinessPreflightFailure` loses its only consumer.
`connectRetry` left the verdicts too: the marker is applied after the connect loop has returned, so
no loop can ever consult it.

The mocked decision tests stop asserting a message, which is what makes them enough: the shape is no
longer part of the rule, and the shapes a real preflight produces are what the device run recorded
rather than what a fixture invents. Restoring the budget conjunction turns the refusal and the
`fetch failed` cases red again, so the tests still discriminate the claim.
…swering

The marker says the preflight gave up, and one of the things a preflight gives up on is a caller that
stopped waiting. `createRequestCanceledError` answers `COMMAND_FAILED` too, so the rule written last
round matched a cancellation exactly as well as a wedged runner: on a session that was ready and
still working, the cancel invalidated the session and replayed the canceled command against a fresh
boot nobody was going to wait for. The main branch rethrew cancellations before any restart branch
could see them; the marker rule made the marker the only question, and the marker does not distinguish
the two shapes it is applied to.

The exception belongs on the rule, where the verdict is, rather than on the call site that happens to
notice: a marked error restarts the session unless the typed reason on it says the request was
canceled. `isRequestCanceledDetails` is exported from the owning type so the rule reads that reason
instead of restating the literal, and `isRequestCanceledError` now asks it. Deleting the conjunct turns
both the classification test and the lifecycle test red — the lifecycle one pins the exact shape from
the report: ready session, marked cancellation, error rethrown, no invalidate called, no second boot.
@thymikee
thymikee force-pushed the fix/apple-runner-typed-recovery-2524 branch from 5c4a3eb to f83bdfe Compare September 15, 2026 08:51
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f83bdfe, as a follow-up to the review at 5c4a3eb. The code looks good: recovery decisions read the typed rules, the deadline case gets a real verdict, and the earlier gaps are closed.

The earlier trade-off question (a marked failure that is not transport-shaped costs one bounded restart) has no reply, but the CHANGELOG describes that behaviour, so I read it as intended.

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.

@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 a901528 into main Sep 15, 2026
17 of 18 checks passed
@thymikee
thymikee deleted the fix/apple-runner-typed-recovery-2524 branch September 15, 2026 09:49
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-15 09:49 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Answered at f83bdfe5bd, merged in a901528351.

The cancellation is a real match, not a hypothetical. createRequestCanceledError answers COMMAND_FAILED, so the rule that asks only "is it marked?" matched a walkaway exactly as well as a wedged runner: ready session, marked cancel, session invalidated, the canceled command replayed against a fresh boot nobody was going to wait for. Main rethrew cancellations before any restart branch saw them, and making the marker the only question removed that.

The exception went on the rule rather than on the call site that happened to notice, because that is where the verdict is: a marked error restarts the session unless the typed reason on it says the request was canceled. isRequestCanceledDetails is exported from the owning type so the rule reads the reason instead of restating the literal, and isRequestCanceledError now asks it.

I did not take the signal?.aborted half of the suggestion. At the point the restart branches sit, that guard would also preempt the post-send resend in handleRunnerTransportErrorAfterCommandSend, which is a different question about a command that was already written, and changing it is outside this issue. It is also not needed for reach: every abort inside the connect loop normalizes to the typed canceled error before it gets here (runner-startup-transport.ts:101, :110, :284, :327, :377, :403), so the reason check sees everything the signal check would have, with no change to the send path.

Tests: the shape joins the classification table, and a cancellation during the readiness preflight does not restart the session it canceled pins the lifecycle case you described — ready session, marked cancellation, error rethrown, invalidateRunnerSession never called, no second ensureRunnerSession. Deleting the conjunct from the rule turns both red; the other four shapes in the table stay green, which is the part that says the carve-out is narrow.

On the trade-off question: yes, that is the intended cost, and it is bounded at one. MAX_RUNNER_RECYCLES_PER_REQUEST is 1, so a marked non-transport failure buys a single new session for the request; a second marked failure in the same request reports ios_runner_recycle_budget_exhausted instead of booting again. The reason to spend it is that the marker is exactly the fact that makes replay safe — the command was never written — so the alternative to restarting is failing a command whose runner has just been observed not to answer. Before this change the failure was silent and shape-dependent, which is why the same runner recovered for some commands and not others.

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.

fix(apple-runner): recovery decisions read typed rules; a connection deadline gets zero recovery today

1 participant