Skip to content

perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner - #2329

Open
thymikee wants to merge 14 commits into
mainfrom
codex/2198-runner-demand
Open

perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner#2329
thymikee wants to merge 14 commits into
mainfrom
codex/2198-runner-demand

Conversation

@thymikee

@thymikee thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

Slice A of #2198. A local iOS Simulator open now derives how much the XCTest runner is needed from the runtime operations declared by the steps still ahead of it in the same batch, and observation stops waiting for runner readiness.

  • Runner demand. runBatch hands every step its place in the plan through the server-private internal request channel (never the wire). The daemon flattens the remaining steps' declared platformExecution uses; snapshot, diff, and find select their alternative from the step input the way their handlers do (selectUses on the descriptor). The Apple owner maps the operations through a record complete over the runtime-operation union. Observation-only plan → none (no runner started or retained). Unknown plan, including an open that ends its batch → possible (today's speculative prewarm, never awaited). Any runner-served operation → required (prewarm now; the first runner-dependent step awaits the same startup under the runner session lock with today's typed errors and deadlines).
  • open --relaunch no longer awaits runner readiness on a Simulator. The runner re-resolves a new app PID at its next command, so targetReset is sent only to a runner that is already alive and ready, or that an awaited prewarm just proved. Physical devices keep their relaunch lifecycle unchanged.
  • wait and read-only find observe runner-free. The Apple native find ports report found: false ("not proven here") instead of starting a runner on a Simulator without a ready session, so the poll consults the canonical AX-bridge tree (ADR 0019 §2). A ready runner keeps answering; provider-backed runners count as live through the runner provider seam.
  • Runner liveness is a provider question and means ready, not registered. A session record exists while xcodebuild is still connecting; treating it as live sent the relaunch reset into a starting runner and the failed reset invalidated the prewarm. Liveness now requires the session's readiness flag; scripted and request-scoped providers with no startup cost count as live.
  • Bounded bridge launch grace. Without the runner wait, the first snapshot after an open reached the AX bridge while the app was still becoming the primary foreground owner or registering its AX server, and the typed fallback then started the runner the plan had just avoided. A target younger than 10 s is re-read for a bounded grace measured from the first such failure: 5 s for a missing AX server, 1 s for an ownership miss so a launch-time system dialog still reaches the fallback quickly. Established targets get no grace; fallback stays typed and at most once.
  • The open response timing gains runnerDemand next to the existing prewarm fields (additive; ADR 0006 untouched). Vocabulary added to CONTEXT.md.

Also folds the two Apple native-find ports onto one admission helper, lifts the runner prewarm policy out of the open sequence (complexity gate), and shares one inert audio-probe fixture through @agent-device/host-kit/audio-probe-fixtures instead of two identical platform copies.

Scope: 41 files, ~1,370 gross lines. Over the 1,000-line budget because the liveness seam, the plan derivation, and the Simulator policy only make sense together with the regressions that fell out of live validation; I can split it into a three-PR stack (liveness seam → plan derivation → Simulator policy) if preferred.

Proxy parity, lease isolation, cancellation, version skew and controlled-RTT measurements remain slice B of #2198.

Validation

Head: bc68c7eabf; base: cf83afb9c9.

  • Unit: a never-resolving prewarm proves a Simulator open never awaits runner readiness; demand none/possible/required; reset only on a live runner, physical-device relaunch unchanged; find ports gated by liveness; provider-scoped liveness; batch step context; descriptor selectors return only declared alternatives; launch grace re-reads young targets, is bounded, and never applies to established targets or non-launch failures.
  • pnpm check:affected --run on the exact head passes every stage up to vitest-related; that stage then trips a pre-existing cross-file leak: the provider-scenario recording flow (ios-record-trace.test.ts) starts a local runner for its clock anchor (its runner call carries no request id, so the request-scoped scripted provider is bypassed by the pinned scope rule), and the runner's stale-xcodebuild pkill cleanup lands in whichever unit file the worker runs next, where the hermetic signal guard fails an unrelated Android open test. The same file set fails the same way on base cf83afb9c9 (first run). The stage passes standalone at two workers (573 files, 4,342 tests), and every stage after it was run individually: provider-integration, integration-progress, replay-compat, daemon-wire-compat, affected-selector, gate-manifest (+model), depgraph, tmpdir-leaks (+model), coverage-model, wire-compat-model, production-exports, bundle-owner-files, fixture-cache, and fixture-fallback all pass; the full check:unit run passes 9,048 of 9,050 (1 skipped) with only that pre-existing leak failing. GitHub stays authoritative for the device lanes.
  • One unit-suite leak that was this change's doing is fixed: a Simulator open's prewarm now outlives its request, so the replay test-runner suite, which opened a Simulator with real Apple tools, needed the runner operations stubbed like its siblings.
  • Live on iPhone 16 Pro / iOS 26.2 Simulator, exact head vs exact base cf83afb9c9 built in a sibling worktree, runner killed before each run, two runs each. A = batch [open --relaunch, snapshot -i, wait text] on the dev-client home screen; B = lone batch [open --relaunch]; C = standalone wait; D = first click after the unawaited relaunch.
run A open (demand) A snapshot A wait B relaunch (demand, waited) C D
base 1 3,904 ms (prewarm awaited 2,528 ms) 2,361 ms bridge 50 ms runner 2,109 ms (awaited, runner live) ok ok
base 2 3,042 ms (prewarm awaited 1,686 ms) 2,260 ms bridge 48 ms runner 2,109 ms (awaited, runner live) ok ok
head 1 1,309 ms (none, no prewarm) 2,237 ms bridge 34 ms canonical tree 2,023 ms (possible, not awaited) ok ok
head 2 1,538 ms (none, no prewarm) 2,529 ms bridge 36 ms canonical tree 2,807 ms (possible, not awaited) ok ok

The observation-only batch starts no runner on head (its wait polls the bridge tree); on base the same batch awaits a cold runner inside open. Head's D pays the remaining runner start it deferred (1.7–3.8 s) where base's runner was already up; that is the intended trade. Sequential runs on one host, not a general speed guarantee. The #2189 cold/warm/relaunch corpus re-run and the first-runner-interaction cell are the acceptance evidence still to publish for #2198.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.49 MB 4.50 MB +7.3 kB
Package (unpacked) 4.49 MB 4.50 MB +7.3 kB
Package (download) 1.33 MB 1.33 MB +2.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 26.4 ms -0.1 ms
CLI --help 79.3 ms 81.1 ms +1.8 ms

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Not ready at bc68c7e.

  • The launch-grace deadline is set from the first failure. An AX-server failure followed by an ownership miss still gets the original 5 seconds instead of the ownership policy's 1 second. Recompute the allowed window without extending it, and add a regression for that transition. Cap polling to the remaining window before starting another acquisition.
  • The guidance-size check fails: CONTEXT.md is 12,354 bytes against a 12,000-byte limit. Trim it rather than raising the limit.
  • Bring in the managed-allocation repair from fix: repair main after the managed-allocation move landed under stale PRs #2328 and rerun the failing checks.
  • The two-run live comparison is useful, but the planned cold/warm/relaunch corpus and first-interaction comparison are still needed for this slice. Proxy parity can remain in the separately scoped follow-up.

Please clarify the runner-retention claim: demand none skips a new prewarm but does not release an existing speculative runner. Either prove the promised release through the existing lifecycle owner, or narrow the claim and explicitly settle that acceptance requirement.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thermo-nuclear code quality review

This slice should not land in its current shape. The demand pipeline is the right idea, but the model still treats optimizations as requirements, misreads default find, and then papers over launch timing with a locale-fragile retry loop in the snapshot route. Those are structural problems, not polish.

Do not approve. Behavior on the happy observation batch is better; the implementation preserves incidental complexity and gets the policy model wrong in ways that will keep growing special cases.

1. Demand treats preferred operations as “must start a runner”

resolvePlannedRuntimeOperations unions required, preferred, and conditional operations, then Apple’s host table marks readTextAtPoint as runner. So batch [open, get] or batch [open, find] with action: 'getText' becomes demand required and starts XCTest.

That fights ADR 0019: preferred is a measured fast path, not a requirement. get / find getText still succeed from the capture tree; the native read is an optimization. #2198’s none is “proven observation-only,” not “no preferred runner facet exists.”

The tests pin the wrong model (getText must include readTextAtPoint). Count required operations for demand. Keep preferred/conditional for admission. Then get/wait/find getText stay none, and find type / click still go required because those legs are required.

That also makes the 80-row host table earn its keep: it classifies what a required operation actually executes, instead of laundering optimizations into prewarm.

2. selectFindStepUses does not mirror the handler, and remaining steps drop positionals

parseFindArgs defaults a missing action to click. The new selector does the opposite:

if (action === undefined || action === 'wait') return selectorUsesByIntent['capture-only'];

{ command: 'find' } is therefore planned as observation-only and executed as a tap. The planned-operations test asserts that hole.

runBatch then copies only command + input into remainingSteps and drops positionals. A daemon step whose action lives in positionals is planned as capture-only even when the running step will click.

Fail closed: unknown or missing find action → full findRuntimePlanUses (already the click/fill branch). Or parse the same way the handler does, including positionals. Do not invent a third action→intent map next to find.ts and selectorCaptureIntent().

3. Launch grace is a second “was this just launched?” source of truth, in the wrong layer

snapshot-route.ts went 287 → 345 (past the 300-line module target). The last commit claims the grace was lifted out of the route; it is still in the same file, as a poll loop keyed on Date.parse(target.processStartTime) from ps -o lstart= text.

Problems that one design change would delete:

  • Wrong layer. Youth is inferred from process display text. Open already owns settle (POST_OPEN_SETTLE_MS). Either stamp a launch timestamp on the target/session, or wait for AX readiness at open. Do not reconstruct “just launched” in the snapshot router.
  • Locale-fragile. Unparseable lstart is treated as established, so grace silently becomes zero off en/Date.parse-friendly machines.
  • Deadline from the first code. An AX-server miss (5s) then an ownership miss still keeps the 5s window. A system dialog must hit fallback in 1s. Recompute the remaining window per code and cap sleep to that remainder. This is a symptom of stuffing policy into a retry loop.

A bounded post-open AX wait, or a typed launch generation on the target, deletes the map, the poll, and the ps parse from the route. Fallback stays one-shot and typed.

4. Policy object is only half a lifecycle decision

RunnerPrewarmPolicy answers start/await. It does not answer release. #2198 and CONTEXT.md say demand none does not start or retain a runner. An existing speculative session survives an observation-only open.

That is not a later nice-to-have. Either stop/idle-stop through the existing lifecycle owner when demand is none, or narrow the vocabulary to “we skip a new prewarm” and drop the retain claim. A boolean shouldPrewarmRunner will keep accumulating hasLiveRunnerSession branches in relaunch notify, find admission, and snapshot fallback.

5. Type / boundary leaks

OpenApplicationPlan.operations: readonly string[] plus the Apple-side casts:

const operations = plan.operations as readonly string[];
const hosts = APPLE_SIMULATOR_OPERATION_HOSTS as Readonly<
  Record<string, AppleSimulatorOperationHost | undefined>
>;

The complete Record<RuntimeOperationKey<...>, ...> is the good part of this PR. The casts throw that exhaustiveness away at the only call site. Type the plan as readonly RuntimeOperationKey<PlatformRuntimeOperations>[] (or pass uses, not names). Unknown strings staying required can remain a runtime fallback without unsafely widening the table.

hasLiveSession? omit-means-live is a silent default on a liveness seam that is easy to get wrong (registered ≠ ready). Scripted providers should say hasLiveSession: () => true explicitly. Optional “no startup cost” is the kind of fallback that sent targetReset into a connecting session last time.

plan on OpenApplicationInput is Apple batch policy on the shared lifecycle contract. Daemon-derived, Apple-consumed is the right ADR 0019 split; the carrier should be a typed operations list (or already-resolved demand) on Apple execution, not an untyped plan? every platform open sees.

selectUses?: (input: Record<string, unknown>) => ... is the right registry extension for snapshot/diff/find. Fold it into the existing uses variant instead of a second sameKeys branch. Keep the selector next to resolveSnapshotRuntimePlan / find’s real action map so it cannot drift.

6. Size / spaghetti (secondary)

  • lifecycle.ts 447 → 490. Extract before the next policy flag; resolveRunnerPrewarmPolicy should not stay in the open sequence file if release/retain joins it.
  • Registry was already >1k; the three selectUses attachments are fine if the selector is the real handler function.
  • admitAppleNativeFind is a real cleanup. simulatorRunnerNotLive as a negative predicate is not; name the positive admission (runnerCanAnswerNow).
  • Fixture share for the audio probe is the one unambiguous simplification. Keep it.

What is already good and should stay: server-private internal.executionPlan (ADR 0006 untouched); Apple-owned operation→host exhaustiveness; Simulator never awaiting prewarm; liveness = alive and ready; physical-device path unchanged; find ports returning found: false instead of starting a runner.

Bar to approve: required-only demand; find selection that matches default-click and still sees positionals; launch timing owned by open/target rather than a ps-parsed retry loop; retain/release actually implemented or the claim removed; plan typed through without string casts. Until then this is a working observation fast path with a policy model that will keep growing branches.

…ion from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198
…runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.
The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.
…targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.
… answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.
… runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.
…steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.
…nd make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.
…budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.
…operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.
Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.
The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Addressed both reviews at d654cc3776 (rebased on main with the #2328 repair).

Demand model

  • Demand counts required operations only; preferred and conditional stay admission-only. get, wait, find … get text now resolve none; find type/focus and touches stay required. Test pins the ADR 0019 reading (planned-operations.test.ts).
  • find is planned from its positionals through the handler's own parser (checkFindArgs): a missing action is a click, and an unparseable step keeps every declared alternative. The handler and the selector share one action→intent map (findRuntimeIntent); find.ts calls it now. Batch steps carry positionals and flags in handler shape; the snapshot selector reads the daemon flag (snapshotCustomActions), not a structured field the daemon never sees.
  • The selectors moved out of contracts to src/core/command-descriptor/step-use-selectors.ts (they need the selectors package). The validator has one uses branch that admits an optional selectUses.

Launch timing

  • The route no longer infers "just launched" from ps text or retries inside capture; that code is gone (snapshot-route.ts 345 → 294 lines). Open owns it: a local-Simulator open asks the bridge whether the launched app is observable (snapshot-observability.ts), with per-code windows measured from the first typed launch-transition failure and never extended. An ownership miss after an AX-server miss shrinks the deadline to the ownership window (regression test), the last poll is capped to the remaining window, and any other failure ends the wait at once. Devices without a bridge, or a bridge that cannot answer, keep the fixed settle. The open response reports postOpenObservation.

Retain/release

  • Narrowed rather than promised: none starts no runner for this plan; an already-live runner stays under the existing idle-stop policy. The contract doc and CONTEXT.md say exactly that. Releasing a speculative runner from the open seam would need the lifecycle owner to know the batch has ended; that is a separate decision, and perf(ios): optimize observation lifecycle and prove proxy parity #2198's acceptance line should read the narrowed wording.

Types and seams

  • The plan reaches the platform as plannedOperations: readonly RuntimeOperationKey<PlatformRuntimeOperations>[] on ApplicationLifecycleExecution; OpenApplicationInput.plan is gone. The Apple table is indexed without casts. One documented cast remains where descriptor uses (built by defineUse, which admits only operation keys) become keys.
  • hasLiveSession is required on every runner provider; a bare executor answers directly by construction (stated in normalizeAppleRunnerProvider), scripted providers say () => true explicitly.
  • resolveRunnerPrewarmPolicy and the observation settle live in open-policy.ts (lifecycle.ts 490 → 449). simulatorRunnerNotLive became runnerCanAnswerNow.

CI

Evidence

@thymikee
thymikee force-pushed the codex/2198-runner-demand branch from bc68c7e to d654cc3 Compare September 6, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant