Files
Michał Pierzchała bbd53d6c79 fix(ios): budget cold toolchain probes for the first-exec signature stall (#2423)
* fix(ios): budget cold toolchain probes for the first-exec signature stall

xcodebuild/xcrun toolchain probes in cache-identity.ts and
runner-cache-metadata.ts were budgeted for a warm toolchain (10s/5s),
below the ~18-19s syspolicyd signature-verification stall on the first
exec after a fresh macOS host boots. Share one 30s floor constant
between both call sites and retry once after a timeout while the
deadline allows, since the second exec is instant.

Closes #2422

* fix(apple): duplicate the cold-toolchain-probe budget instead of a shared module

toolchain-probe-budget.ts sat outside every platform-apple facade's eager
closure, but runner-cache-metadata.ts (imported from it) sits inside all
seven -- so the new import added one module to each, tripping the
eager-closure-budgets no-growth gate (#2422).

Delete the shared module. cache-identity.ts keeps the canonical constant
inline (it was already outside the gated closures); runner-cache-metadata.ts
declares its own copy, guarded by a new unit test that asserts the two
stay equal.

* fix(apple): bound the cold toolchain probes by the owning request budget

Three synchronous probes could each retry once at 30 s, so a wedged
toolchain host blocked a request for ~180 s with no deadline and no
cancellation check.

The runner cache decision now takes the owning request's budget
(remaining ms + abort signal) and builds one clock per fingerprint read:
every attempt runs at min(per-call ceiling, remaining), the retry is
skipped once the budget is spent, an exhausted budget fails the decision
without starting another probe, and an aborted signal surfaces the
cancellation instead of retrying. `ensureXctestrunArtifact` passes the
build budget and signal, session reuse passes the startup budget and the
request signal, and lease adoption passes the startup budget; a caller
with neither is still capped at 45 s total, so the worst case falls from
~180 s to 45 s. Error codes, texts, and the probe hint are unchanged.

Both retry classifiers now read the exec layer's structured timeout
detail instead of matching "timed out after Nms" in the message. The
predicate is exported once from host-kit's command surface and reaches
`runner-cache-metadata.ts` through the Apple runner host port, so the
file's eager closure is unchanged.

Tests use a fake clock that only advances when a probe actually blocks
for the timeout it was given, so the exhausted-budget and cancellation
cases have to spend the budget to pass; both consumers also pin that an
error saying "timed out after 10ms" without the structured detail is not
retried.

Refs #2422

* fix(apple): spend one deadline across the toolchain probes and the step they precede

The Apple runner cache decision runs up to three blocking toolchain probes
before the step that needs the decision. Those probes were handed the phase's
timeout and the step was then handed the same number again, so a cold-start
probe stall added its 30 to 45 seconds on top of the phase budget instead of
coming out of it.

RunnerCacheProbeBudget now carries the phase's deadline rather than a timeout
number, and each caller creates exactly one:

- ensureXctestrunArtifact: the probes and xcodebuild read the same clock, and a
  phase with nothing left fails before the spawn.
- ensureRunnerSession: the reuse probe spends from the startup clock, and the
  new session gets what it left.
- tryAdoptRunnerSessionFromLease: the fingerprint probe spends from the caller's
  clock, and the adopted session gets the remainder.

COLD_TOOLCHAIN_PROBE_TIMEOUT_MS now has one owner, core/config.ts. Snapshot
source imports it; runner-cache-metadata reads it through the Apple runner host
port, because core/config.ts is missing from one of the seven eager closures
that evaluate that file and a direct import would grow it.

createSnapshotSourceDeadline takes an injectable clock so a test can prove that
a probe which blocked for its whole timeout leaves the retry only the remainder.

* fix(apple): keep cancellation typed after a probe timeout and simplify the probe budget

* fix(apple): check cancellation before the warm toolchain fingerprint cache

* refactor(apple): guard each toolchain probe attempt in one place

Fold the toolchain probe's three duplicated cancellation/budget guard
sites (runToolchainProbe's pre-check, runToolchainProbeCommand's
retry pre-check, and execToolchainProbeCommand's timeout computation)
into one: attemptToolchainProbe checks cancellation and the remaining
budget before every exec, first attempt and retry alike. The outer
runToolchainProbe now rethrows cancellation and a spent budget instead
of swallowing them into a probe failure, and only genuine probe errors
become one.

* fix(apple): keep cancellation typed when the final toolchain probe fails

The guard fold left one gap: a request that aborts while the last probe is
in flight and then fails with a non-timeout error has no next attempt whose
guard could see the abort, so the catch classified it as an unreadable
toolchain. The catch checks the signal again before classifying, as it did
before the fold.

* refactor(apple): own the toolchain probe budget in platform-apple and trim narration

COLD_TOOLCHAIN_PROBE_TIMEOUT_MS moves from @agent-device/host-kit/command to
runner/apple-runner-platform.ts, beside the SDK names the probes are run
against. Both Apple toolchain probers import it directly, so the runner host
port no longer carries a coldToolchainProbeTimeoutMs() accessor for a plain
number. isCommandTimeoutError stays in host-kit, where the exec layer stamps
the detail it reads.

The comments that narrated control flow the code already shows are gone; the
cold-start stall rationale (on the constant), the spawnSync cancellation
limitation (on the probe clock) and one line per phase-deadline creation site
remain.
2026-09-10 16:31:56 +02:00
..