Skip to content

refactor(ios-runner): one response decoder, a session state enum, and a verdict on the curl-through-simctl transport #2662

Description

@thymikee

Remaining scope in the deletion-first initiative

#2803 now includes task 3 (the curl-through-simctl verdict) in its measurement workstream. The decoder/state-enum work has already landed; do not repeat it from the historical descriptions below.

Before deleting postCommandViaSimulator, establish that the control route is reachable and reproduce or explicitly account for the original failure condition. A week with no fallback observations is insufficient if the relevant simulator-set/toolchain/network condition was never exercised. Record baseline SHA, route selection, successful primary/fallback controls, timeout/cancellation outcomes, and a retain/delete verdict. Preserve #2963's scoped simulator-set behavior; coordinate overlapping edits with that PR. If retained, name the actual condition and evidence. If deleted, report all removed production paths/encoders and the net production-line delta, with tests separate. Temporary instrumentation is removed or justified as an owning diagnostic.

The public dispatch-outcome discussion remains outside this cleanup scope. Historical task descriptions follow.


Why

The largest maintenance surface on the Apple platform is not geometry, it is the daemon-side cluster that babysits the XCTest runner process: packages/platform-apple/src/runner/ is 38 production files, about 10k lines. A survey on main at 91652a8fc5 found the following (file:line refer to that head):

  • Three response decoders for one wire. parseRunnerResponsePayload in runner-session.ts:973 (the canonical one), parseLifecycleResponsePayload in runner-command-recovery.ts:353 (the status recovery probe), and an inline JSON.parse in runner-adoption.ts:140 (the uptime probe).
  • Four command-encode paths. runner-transport.ts:48 (sendRunnerCommandOnce, host TCP / usbmux), runner-usbmux.ts:52 (raw HTTP framing over the usbmux socket), runner-startup-transport.ts:371 (tryRunnerEndpoints, the startup connect probe) and runner-startup-transport.ts:418 (postCommandViaSimulator: simctl spawn <udid> /usr/bin/curl …, a shell-out inside the Simulator, still reachable from lines 119 and 400).
  • Six notions of "alive". isRunnerProcessAlive / isRunnerProcessTreeAlive / runnerSessionsStillAlive in runner-disposal.ts:274/269/177; hasLiveIosRunnerSession in runner-client.ts:192 reading getRunnerSessionSnapshot in runner-session.ts:442; probeRunnerAnswersUptime in runner-adoption.ts:127 (wire-level); and canSkipRunnerReadinessPreflightAfterHealthyMutation in runner-command-traits.ts:56, a separate "healthy enough to skip the preflight" axis.
  • No runner state. RunnerSession (runner-session-types.ts) has no state field, only booleans (ready, computed alive, lastHealthyMutation); the readiness-preflight decision in runner-session.ts:87–101 and the cache decision in runner-cache.ts:450–462 are string-literal unions that stand in for one.
  • 29 timeout/budget constants across 12 files (runner-startup-transport.ts:36–40, runner-disposal.ts:26–33, runner-session.ts:78–81, runner-lease.ts:23–25, runner-device-set.ts:18–20, runner-sequence.ts:24–35, and single constants in six more files).

None of this is a bug. It is where the next incident will take longest to diagnose, and it is the code a contributor has to read to touch anything about runner startup.

Task

Three bounded cuts, each its own PR, in this order. Do not attempt a rewrite.

  1. One response decoder. Make parseRunnerResponse (runner-session.ts) the only place a runner response body is decoded; have the recovery status probe and the adoption uptime probe call it (or a narrower function it exports) and delete the two private decoders. Tests: runner-command-recovery and runner-adoption suites keep their current expectations; add one test per probe proving a malformed body is rejected the same way the main path rejects it.
  2. A session state enum. Add state: 'starting' | 'ready' | 'draining' | 'stopped' (adjust names to what the code actually distinguishes; derive from the existing booleans and the readiness-preflight reasons, do not invent states) to RunnerSession, with one transition function. Replace the six "alive" checks with two: process liveness (OS fact, host.ts) and session state. hasLiveIosRunnerSession becomes a state read. The readiness-preflight decision keeps its reason codes (they are diagnostics), but the decision reads state plus lastHealthyMutation. Any SessionState-like field added to a persisted record follows the R7/R10 rule in CONTEXT.md (owner entry plus schema bump).
  3. Retire postCommandViaSimulator if it is dead. Establish first whether the curl-through-simctl path ever answers where the host TCP and usbmux paths do not, after usbmux became primary (Explore usbmux as the primary physical iOS runner transport #1403). Instrument with a diagnostic (ios_runner_startup_transport phase, which transport answered) and read it over the iOS simulator lanes and the nightly (.github/workflows/xctest-nightly.yml) for a week, or find the commit that introduced it and the failure it worked around. If no run needs it, delete it together with its encode path; if one does, document the condition next to the function and close this sub-task.

Acceptance criteria

  • After (1): grep -rn 'JSON.parse' packages/platform-apple/src/runner/*.ts (production files) shows exactly one site; pnpm check:affected --run green.
  • After (2): RunnerSession has a state field; grep -rn 'alive' packages/platform-apple/src/runner/*.ts shows only the process-liveness primitive and reads of state; the daemon device status output for a running iOS session is unchanged (compare JSON against main); the iOS simulator integration lane and the macOS host lane green; one recorded startup, one idle-stop and one recycle each transition through the enum, asserted in runner-session tests.
  • After (3): either the function and its encode path are gone and the startup transport tests are updated, or a comment above it names the concrete condition it serves with the run that proved it.
  • No timeout value changes in any of the three PRs (the constants are a smell, not a target; changing them changes startup timing, which Cold iPhone startup ignores the requested preparation deadline #2324/fix(ios): honor the startup budget through a cold Simulator boot #2325 calibrated).
  • The layering check (pnpm check:layering) and pnpm check:production-exports stay green; no new exports from packages/platform-apple/src/runner/index.ts.

Non-goals

Merging or renaming files for their own sake. Changing lease, cache or xctestrun-artifact logic (runner-lease.ts, runner-cache*.ts, runner-artifact*.ts): those are a different cluster with their own invariants (#2598). Reducing the number of timeout constants.

Related: #1403 (usbmux primary), #2324 / #2325 (startup budget), #2598 (process lock), ADR 0005 (runner interaction lifecycle), ADR 0019 (request-bound platform runtime).

Activity

  1. thymikee commented on Sep 18, 2026

    @thymikee
    MemberAuthor

    (1) landed in #2666 (c57649357b). Three things the next cut needs from it:

    The one decoder lives in runner-contract.ts, not runner-session.ts. runner-adoption.ts cannot import runner-session.ts (session imports adoption, and the layering rule rejects value cycles), and a separate module was refused by eager-closure-budgets: runner-session.ts sits in the eager closure of the app-lifecycle, doctor and runner-operations facades, so one more module takes those closures past the merge-base count. runner-contract.ts is already evaluated by all three facades and already imported by all three readers — it is the only cycle-free home that keeps that gate green, and the envelope is the response half of the contract that file already owns. parseRunnerResponse stays the single place that decides success.

    The literal grep still shows five JSON.parse sites in runner/*.ts. runner-contract.ts:492 is the only one decoding a runner response body; the rest read lease files (twice), cache metadata, and tool stdout — the #2598 cluster this issue puts out of scope.

    ok acceptance is now === true (the Swift Bool). Every body a real runner can send behaves exactly as before; a body reading "ok":"true" stops counting as a completed command's own retained result, which is the intended tightening.

    For (3), the archaeology branch is empty: git log -S puts the curl-through-simctl path in the initial commit 4da4745a6c (2026-01-30), with no introducing commit and no failure it worked around anywhere in history. So the verdict is only available from the ios_runner_startup_transport instrumentation read over the lanes, or from deciding to delete it.

    One hole found while doing (1), deliberately left because (1) promised no behavior change: JSON that parses but is not an object (42, "x") decodes to {} at runner-contract.ts:492, buildRunnerResponseError then stamps runner: {}, and isStructuredRunnerFailure (runner-session.ts:820) reports true — so a body that is not an envelope at all is read as "the runner served an answer" and clears runnerMainThreadBusy (runner-session.ts:800). A truncated body is handled correctly: transport-shaped, stamp kept (#2552). The candidate fix is one line — a non-object decode throws the same Invalid runner response AppError as malformed text — but it changes what counts as an answer, so it belongs in (2), where the session stops reconstructing "did the runner answer" from booleans.

  2. thymikee commented on Sep 19, 2026

    @thymikee
    MemberAuthor

    Follow-up on sub-task (2), for after the session state enum lands.

    RunnerSession already has state: 'starting' at packages/platform-apple/src/runner/runner-session.ts:283 and advanceRunnerSessionState is in use, so the enum half of (2) is on main since 33d48a14. Worth checking before someone re-does it.

    The half I actually want is the one that escapes the runner directory. The post-dispatch outcome is already decided internally, and decided correctly:

    • Mutations are sent exactly once. Only read-only commands take the retrying waitForRunner connect loop (runner-session.ts:903); a mutating command goes through one send after the readiness preflight.
    • When the response is lost after dispatch, runner-command-recovery.ts:237-272 asks the runner's status for a retained response and then distinguishes completed_with_retained_response (payload recovered) from completed_without_retained_response (the command ran, the answer is gone), surfacing the latter as a COMMAND_FAILED whose details carry lifecycleState: 'completed' and recovery.

    So we have the three states — ran, never dispatched, outcome unknown — but only two of them are names a caller can depend on. What reaches an SDK consumer for "the tap probably landed and I cannot prove it" is an error envelope with details.lifecycleState, i.e. exactly the stringly-typed sniffing this issue exists to remove one level down.

    Ask: once (2) is in, give the outcome a name at the contract boundary. In packages/contracts/src, a documented result field (something like dispatchOutcome: 'completed' | 'not-dispatched' | 'unknown') derived from the session state plus the recovery reason, with the mapping in one place. details.lifecycleState and details.recovery can stay as diagnostics.

    Why this belongs to this issue rather than its own: the producer is the state enum from (2) and the decoder from (1), and the acceptance bar is the same — one source of truth, no second reader allowed. There is external demand for it; a consumer of ours is reportedly reimplementing single-attempt mutation policy on top of our error text because it cannot tell these three cases apart from the public response. That report is secondhand, but the reason to publish the field does not depend on it.

  3. thymikee commented on Oct 2, 2026

    @thymikee
    MemberAuthor

    Disposition at main e4716e6f514273f060b4f1fb8a523d3c51a9c17d: close the remaining investigation as not planned, retaining the simulator curl transport. This is a prioritization decision, not a claim that task 3 was implemented or transferred.

    The response decoder and session state have already landed. The residual postCommandViaSimulator path is still reachable from startup connection attempts and from the final simulator fallback in runner-startup-transport.ts. The function is about 50 lines, and there is no demonstrated correctness defect or measured deletion benefit sufficient to justify a separate instrumentation campaign. We have not established that it is redundant. Retain it, its encoder, scoped simulator-set routing, and timeout/cancellation behavior. A concrete transport incident or a measured removal case can reopen this work.

    The public consumer ask now has an owning contract: #3071 and #3082, followed by #3099, expose typed error.details.dispatched: "no" | "unknown", driven by contracts/fixtures/dispatch-disclosure.json and documented in ADR 0011. Apple transport and status recovery classify pre-dispatch refusals and lost replies there. A recovered result succeeds; a failed mutation whose reply is lost is unknown, including completed-without-retained-reply. We deliberately do not add a second dispatchOutcome field or promise completed on a failure. This is the retry-safety decision consumers need without reading lifecycle/recovery strings.

    #2803 is being reconciled to remove the outstanding curl investigation. No transport code is deleted by this disposition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions