From 8b80f662a3c1512a5977480690215e33503f8b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:54:20 +0200 Subject: [PATCH 1/4] fix(ios): derive the fold request envelope from a worst-case ledger test FOLD_REQUEST_TIMEOUT_MS was a literal whose basis lived only in a comment, and that comment kept drifting from the route: it first cited one 30s helper build and four 20s hinge reads, missing two 5s display-inventory reads and the HID-dispatch guard/grace, then #2858 hand-summed the route again for its own preparation-phase change and landed a fresh hand-summed figure with the same failure mode. Add a ledger test that drives fold through the public client and daemon against a fake Apple tool provider recording each call's timeoutMs, including the fold-helper cache's toolchain probes and build. A calibration run learns the route's hinge-settle-attempt count from an oscillating angle that never settles; a measured run uses the same oscillation but lands the last allowed read on target, so it succeeds after making exactly as many reads. Summing every call's timeout (minus 1ms, plus any kill grace) gives the route's worst case that still succeeds, asserted against the resolved command envelope with the usual 30s daemon-result margin. The test imports no platform-apple step figure other than MAX_FOLD_DURATION_MS, so it keeps proving the bound however the route's steps change next. Widen FOLD_REQUEST_TIMEOUT_MS to 255_000 (the smallest 5s multiple at least ledger + margin) and point both the constant's comment and the descriptor-timeout-policy pinning test at the ledger test instead of a hand-summed figure. --- .../command-registry/src/timeout-policy.ts | 13 +- .../command-descriptor-timeout-policy.test.ts | 7 +- .../provider-scenarios/ios-fold.test.ts | 214 +++++++++++++++++- 3 files changed, 218 insertions(+), 16 deletions(-) diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index ca17ee37b7..22d917e3b9 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -43,16 +43,11 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = { }; /** - * `fold`'s worst case sums every step budget on the route (platform-apple owns the constants; - * command-registry does not import them, so the figures below are copied, not derived): - * - display-inventory query (foldable check): 5s - * - fold-helper preparation (toolchain probe + build): 60s - * - HID dispatch (60s max keyframe duration + 10s): 70s - * - hinge settle reads (4 attempts x 20s): 80s - * - lit-panel display-inventory query: 5s - * total: 220s. The envelope below covers that with margin. + * Covers the fold route's worst-case ledger (display inventory, fold-helper preparation, HID + * dispatch, hinge settle reads, final display inventory) plus the daemon-result margin. Proven by + * the ledger test: `test/integration/provider-scenarios/ios-fold.test.ts`. */ -const FOLD_REQUEST_TIMEOUT_MS = 240_000; +const FOLD_REQUEST_TIMEOUT_MS = 255_000; export const FOLD_TIMEOUT_POLICY: CommandTimeoutPolicy = { budget: { source: 'none' }, diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 906cf289c0..d5e3ade0b8 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -143,10 +143,9 @@ test('request envelopes deviating from the default are bounded, reviewed sets', reinstall: 180_000, install_source: 180_000, longpress: 210_000, - // fold: display-inventory query + fold-helper preparation + HID dispatch + hinge settle - // reads + lit-panel display-inventory query can sum to 220s worst case; the policy covers - // that with margin. - fold: 240_000, + // fold: proven by the worst-case ledger test in + // test/integration/provider-scenarios/ios-fold.test.ts. + fold: 255_000, // #1774: base allocation budget (300s) + client/daemon race margin (30s). lease_allocate: 330_000, test: 'unbounded', diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 49beb7d31e..b8b7e3279a 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -1,13 +1,17 @@ import { formatPortableActionLine, parseReplayScriptDetailed } from '@agent-device/ad-script'; +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { MAX_FOLD_DURATION_MS } from '@agent-device/contracts/device'; +import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import { recordActionEntry } from '../../../src/daemon/session-action-recorder.ts'; -import { assertRpcOk } from './assertions.ts'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; import { makeIosAppSession } from '../../../src/__tests__/test-utils/session-factories.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, test } from 'vitest'; -import { createProviderScenarioHarness } from './harness.ts'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { createProviderScenarioHarness, type ProviderScenarioRpcResult } from './harness.ts'; import { createRecordingAppleToolProvider } from './providers.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; @@ -124,3 +128,207 @@ test('timed fold keyframes reach simulator HID through the public client and dae await daemon.close(); } }); + +// The fold request envelope (`FOLD_REQUEST_TIMEOUT_MS`, packages/command-registry/src/ +// timeout-policy.ts) must cover the route's worst-case wall time plus the daemon-result margin +// (REQUEST_TIMEOUT_BUDGET_MARGIN_MS, same file), or a still-progressing fold trips the client +// envelope and resets the daemon before the route's own typed result arrives. This test proves +// that bound against the real route rather than a hand-summed comment: it never imports a +// platform-apple step figure, so it stays true however the route's steps change. +type FoldLedgerCall = Readonly<{ + tool: 'runCommand' | 'simctl' | 'devicectl'; + args: readonly string[]; + timeoutMs: number; + graceMs?: number; +}>; + +/** Two readings this far apart never settle (`IOS_FOLD_POSE_STABLE_DEGREES` is 0.5°), though each + * one alone matches the 100° target within it — the hinge keeps oscillating without resting. */ +function alternatingHingeAngle(readIndex: number): number { + return readIndex % 2 === 0 ? 99.6 : 100.4; +} + +function createFoldLedgerAppleToolProvider(params: { + hingeAngleAt: (readIndex: number) => number; + onCall: (call: FoldLedgerCall) => void; +}): { provider: AppleToolProvider; ledger: FoldLedgerCall[]; hingeReadCount: () => number } { + const ledger: FoldLedgerCall[] = []; + let hingeReads = 0; + const ok = { stdout: '', stderr: '', exitCode: 0 }; + const record = ( + tool: FoldLedgerCall['tool'], + args: readonly string[], + options?: { timeoutMs?: number; kill?: { graceMs: number } }, + ): void => { + const call: FoldLedgerCall = { + tool, + args, + timeoutMs: options?.timeoutMs ?? 0, + ...(options?.kill ? { graceMs: options.kill.graceMs } : {}), + }; + ledger.push(call); + params.onCall(call); + }; + const provider: AppleToolProvider = { + whichCommand: async () => true, + runCommand: async (cmd, args, options) => { + if (cmd === 'open' && args.join(' ') === '-a Simulator') return ok; + record('runCommand', [cmd, ...args], options); + return ok; + }, + simctl: { + run: async (args, options) => { + record('simctl', args, options); + return ok; + }, + }, + devicectl: { + run: async (args, options) => { + record('devicectl', args, options); + if (args.includes('hinge-angle')) { + const angle = params.hingeAngleAt(hingeReads); + hingeReads += 1; + return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; + } + assert.ok(args.includes('displays'), `unexpected devicectl call: ${args.join(' ')}`); + const displays = [0, 1].map((displayId) => ({ + name: `LCD-${displayId}`, + displayId, + nativeSize: [2007, 2853], + pointScale: 3, + type: { integrated: {} }, + active: displayId === 1, + })); + fs.writeFileSync( + args[args.indexOf('--json-output') + 1]!, + JSON.stringify({ result: { displays } }), + ); + return ok; + }, + }, + }; + return { provider, ledger, hingeReadCount: () => hingeReads }; +} + +/** + * Runs one cold fold through the public client and daemon against a fake Apple tool provider that + * records `{ tool, args, timeoutMs, graceMs }` per call. When `withVirtualClock` is set, `Date.now` + * is spied so every deadline the route reads (host-kit `Deadline`, snapshot-source/deadline.ts) + * sees each call as having spent `timeoutMs - 1` plus any kill grace: the worst case that still + * succeeds, never an actual timeout. + */ +async function runFoldLedgerScenario(params: { + hingeAngleAt: (readIndex: number) => number; + withVirtualClock: boolean; +}): Promise<{ + response: ProviderScenarioRpcResult; + ledger: FoldLedgerCall[]; + hingeReadCount: number; + virtualElapsedMs: number; +}> { + const trajectory = [ + { atMs: 0, angle: 0 }, + { atMs: MAX_FOLD_DURATION_MS, angle: 100 }, + ]; + let virtualElapsedMs = 0; + const originNowMs = Date.now(); + const dateSpy = params.withVirtualClock + ? vi.spyOn(Date, 'now').mockImplementation(() => originNowMs + virtualElapsedMs) + : undefined; + const { provider, ledger, hingeReadCount } = createFoldLedgerAppleToolProvider({ + hingeAngleAt: params.hingeAngleAt, + onCall: (call) => { + virtualElapsedMs += Math.max(0, call.timeoutMs - 1) + (call.graceMs ?? 0); + }, + }); + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + appleToolProvider: () => provider, + }); + daemon.setSession( + 'default', + makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), + ); + try { + const response = await daemon.callCommand('fold', [], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + keyframes: JSON.stringify(trajectory), + }); + return { response, ledger, hingeReadCount: hingeReadCount(), virtualElapsedMs }; + } finally { + dateSpy?.mockRestore(); + await daemon.close(); + } +} + +function assertFoldLedgerPhaseCoverage(ledger: readonly FoldLedgerCall[]): void { + const isBuild = (call: FoldLedgerCall) => + call.tool === 'runCommand' && call.args.includes('clang'); + const isSpawn = (call: FoldLedgerCall) => call.tool === 'simctl' && call.args.includes('spawn'); + const isHingeRead = (call: FoldLedgerCall) => + call.tool === 'devicectl' && call.args.includes('hinge-angle'); + const isDisplayInventory = (call: FoldLedgerCall) => + call.tool === 'devicectl' && call.args.includes('displays'); + + assert.ok(ledger.some(isBuild), 'ledger is missing the helper-build phase'); + assert.ok(ledger.some(isSpawn), 'ledger is missing the HID-dispatch (simctl spawn) phase'); + assert.ok(ledger.some(isHingeRead), 'ledger is missing the hinge-settle-read phase'); + assert.equal( + ledger.filter(isDisplayInventory).length, + 2, + 'ledger must record both the foldable-check and the lit-panel display inventory reads', + ); +} + +test('fold worst-case ledger covers the client envelope with the daemon-result margin', async () => { + // Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts. + // Not imported: it is not exported (fallow would flag an export used only by a test). + const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; + + // Calibration: learns the route's settle-attempt budget (N) instead of assuming it. Needs no + // virtual clock, because only the read count is asserted here. + const calibration = await runFoldLedgerScenario({ + hingeAngleAt: alternatingHingeAngle, + withVirtualClock: false, + }); + const calibrationError = assertRpcError( + calibration.response, + 'COMMAND_FAILED', + /did not settle/, + ) as { details?: { reason?: unknown } }; + assert.equal(calibrationError.details?.reason, 'fold-pose-unsettled'); + const settleAttempts = calibration.hingeReadCount; + assert.ok( + settleAttempts >= 2, + `calibration run made ${settleAttempts} hinge reads; expected at least 2`, + ); + + // Measured: the same alternation for every read but the last, which lands exactly on target so + // the route settles on the very last read it allows — the worst case that still succeeds. + const measured = await runFoldLedgerScenario({ + hingeAngleAt: (readIndex) => + readIndex === settleAttempts - 1 ? 100 : alternatingHingeAngle(readIndex), + withVirtualClock: true, + }); + const measuredData = assertRpcOk(measured.response); + assert.equal(measuredData.hingeAngleDegrees, 100); + assert.equal( + measured.hingeReadCount, + settleAttempts, + 'the measured run must make exactly as many hinge reads as the calibration run', + ); + + assertFoldLedgerPhaseCoverage(measured.ledger); + + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('fold'), { + positionals: [], + flags: {}, + }); + assert.ok(envelopeMs !== undefined, 'fold must declare a bounded envelope'); + assert.ok( + measured.virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs!, + `fold ledger worst case (${measured.virtualElapsedMs}ms) + ${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms margin ` + + `exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(measured.ledger, null, 2)}`, + ); +}); From 047e4654a31fb01036096656a35bfdecdd7cf145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:55:31 +0200 Subject: [PATCH 2/4] fix(ios): keep a daemon-result margin over prepare's default runner budget readPrepareIosRunnerTimeoutMs's fallback and the client envelope both resolved to PREPARE_REQUEST_TIMEOUT_MS (240_000), so with no --timeout the daemon-side runner budget and the client envelope were identical: a slow cold runner build ended in a client-side timeout and daemon reset instead of the daemon's own typed runner_phase_budget result. Every other bounded command keeps a 30s margin between its daemon-side budget and its envelope; prepare's explicit-timeout case already widened past its own base by that same margin (widenToUserBudget), only the default case had none. Split the daemon-side runner budget out as PREPARE_STARTUP_BUDGET_MS (240_000, unchanged) and derive PREPARE_REQUEST_TIMEOUT_MS from it plus the margin (270_000). The handler falls back to the startup budget, not the now-wider request timeout. Add a handler-level test that calls handlePrepareCommand directly with a fake runner binding recording the timeoutMs it receives, then checks that value plus the margin against the same request's resolved client envelope for the no-flag, --timeout above default, and --timeout below default cases - the rule the fix proves, not just the constants it compiles to. --- .../command-registry/src/timeout-policy.ts | 7 +- .../command-descriptor-timeout-policy.test.ts | 5 +- .../__tests__/session-prepare.test.ts | 130 ++++++++++++++++++ src/daemon/handlers/session-prepare.ts | 4 +- 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-prepare.test.ts diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 22d917e3b9..30e6f58f61 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -5,7 +5,6 @@ import type { CommandTimeoutBudget, CommandTimeoutPolicy } from './types.ts'; // declared per command on the descriptors, so their values live beside them. const DAEMON_REQUEST_TIMEOUT_MS = 90_000; -export const PREPARE_REQUEST_TIMEOUT_MS = 240_000; // Keep this above the longest platform install subprocess timeout so the client // envelope does not abort a still-progressing device install first. @@ -16,6 +15,12 @@ export const INSTALL_REQUEST_TIMEOUT_MS = 180_000; // envelope below the command's declared base. const REQUEST_TIMEOUT_BUDGET_MARGIN_MS = 30_000; +/** Daemon-side runner budget for `prepare` without `--timeout` (`readPrepareIosRunnerTimeoutMs`). */ +export const PREPARE_STARTUP_BUDGET_MS = 240_000; + +export const PREPARE_REQUEST_TIMEOUT_MS = + PREPARE_STARTUP_BUDGET_MS + REQUEST_TIMEOUT_BUDGET_MARGIN_MS; + /** * How long a lease lifecycle provider may spend allocating one lease (cloud * device allocation: BrowserStack iOS ~45–90s, AWS remote access ~2 min to diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index d5e3ade0b8..d8d444d278 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -138,7 +138,8 @@ test('settle timeout policy default matches the runtime settle loop default', () test('request envelopes deviating from the default are bounded, reviewed sets', () => { const EXPECTED_ENVELOPES: Record = { - prepare: 240_000, + // prepare: daemon-side runner budget (PREPARE_STARTUP_BUDGET_MS) plus the daemon-result margin. + prepare: 270_000, install: 180_000, reinstall: 180_000, install_source: 180_000, @@ -317,7 +318,7 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid ...base, positionals: ['ios-runner'], }), - 240_000, + 270_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('test'), { ...base }), diff --git a/src/daemon/handlers/__tests__/session-prepare.test.ts b/src/daemon/handlers/__tests__/session-prepare.test.ts new file mode 100644 index 0000000000..5804f8e8b5 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-prepare.test.ts @@ -0,0 +1,130 @@ +import { expect, test, vi } from 'vitest'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { createUnavailableRuntimeFactsForTest } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../request-runtime-binding.ts'; +import { handlePrepareCommand } from '../session-prepare.ts'; + +// `resolveCommandDevice` widens to `resolveTargetDevice` for an explicit selector (this test +// always supplies `--udid`), which otherwise reaches the real device-selection dispatcher. +vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, resolveTargetDevice: vi.fn(async () => IOS_SIMULATOR) }; +}); + +// Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts. Not +// imported: it is not exported (fallow would flag an export used only by a test). +const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; + +function prepareRuntimeFacts(): RuntimeFacts { + const unavailableFacts = createUnavailableRuntimeFactsForTest( + IOS_SIMULATOR, + localRuntimeOwner('apple'), + ); + return { + ...unavailableFacts, + operations: { ...unavailableFacts.operations, prepareAppleRunner: { available: true } }, + }; +} + +/** + * Runs `prepare ios-runner` through the production handler with a fake runner binding that + * records the `timeoutMs` it was handed, then checks that value (plus the daemon-result margin) + * against the same request's resolved client envelope — the rule 1d proves, not just the + * constants it happens to compile to. + */ +async function runPrepare(flags: { timeoutMs?: number }): Promise<{ + handlerTimeoutMs: number; + envelopeMs: number | undefined; +}> { + const sessionName = 'prepare-envelope-margin'; + const sessionStore = makeSessionStore('agent-device-prepare-handler-'); + sessionStore.set(sessionName, { + name: sessionName, + device: IOS_SIMULATOR, + createdAt: Date.now(), + actions: [], + }); + + let handlerTimeoutMs: number | undefined; + const inspectFacts: InspectDeviceRuntimeFacts = async () => prepareRuntimeFacts(); + const bindDevice: BindDeviceRuntime = async (device, use) => + narrowDeviceBinding( + { + device, + owner: localRuntimeOwner('apple'), + facts: prepareRuntimeFacts(), + operations: { + prepareAppleRunner: async (input: { timeoutMs: number }) => { + handlerTimeoutMs = input.timeoutMs; + return { runner: {}, connectMs: 1, healthCheckMs: 1 }; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }, + use, + ); + + const positionals = ['ios-runner']; + const requestFlags: CommandFlags = { + udid: IOS_SIMULATOR.id, + platform: 'ios', + ...flags, + }; + const response = await handlePrepareCommand({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals, + flags: requestFlags, + }, + sessionName, + logPath: '/dev/null', + sessionStore, + inspectFacts, + bindDevice, + }); + + expect(response?.ok, JSON.stringify(response)).toBe(true); + expect(handlerTimeoutMs).toBeDefined(); + + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { + positionals, + flags: requestFlags, + }); + return { handlerTimeoutMs: handlerTimeoutMs!, envelopeMs }; +} + +test('prepare ios-runner with no --timeout keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({}); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); + +test('prepare ios-runner --timeout 300000 keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({ timeoutMs: 300_000 }); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs).toBe(300_000); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); + +test('prepare ios-runner --timeout 60000 keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({ timeoutMs: 60_000 }); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs).toBe(60_000); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); diff --git a/src/daemon/handlers/session-prepare.ts b/src/daemon/handlers/session-prepare.ts index 88e3f15623..0a6944b5fd 100644 --- a/src/daemon/handlers/session-prepare.ts +++ b/src/daemon/handlers/session-prepare.ts @@ -1,7 +1,7 @@ import { prepareAppleRunnerRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { PREPARE_REQUEST_TIMEOUT_MS } from '@agent-device/command-registry/timeout-policy'; +import { PREPARE_STARTUP_BUDGET_MS } from '@agent-device/command-registry/timeout-policy'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { resolveRunnerLogicalLeaseContext } from '../lease-context.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; @@ -87,7 +87,7 @@ function readPrepareIosRunnerTimeoutMs(req: DaemonRequest): number { const value = req.flags?.timeoutMs; return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value - : PREPARE_REQUEST_TIMEOUT_MS; + : PREPARE_STARTUP_BUDGET_MS; } function prepareIosRunnerResponseData( From 1421a457f53043487c6bd87984d2a8f78c196623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:06:16 +0200 Subject: [PATCH 3/4] fix(ios): fail the fold ledger test on an unbounded provider call The fold worst-case ledger fake defaulted an unset timeoutMs to 0, so a provider call reaching it with no bound cost 0 virtual ms instead of exposing the true unbounded worst case. Its runCommand handler also skipped `open -a Simulator` before recording, dropping any such call from the ledger with no error. Build the ledger on the shared recording provider and assert every call carries a finite, positive timeoutMs, so an unbounded call fails the test instead of passing silently. --- .../provider-scenarios/ios-fold.test.ts | 213 ++++++++++-------- 1 file changed, 124 insertions(+), 89 deletions(-) diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index b8b7e3279a..2e44e99ba7 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -3,6 +3,7 @@ import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/regi import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; import { MAX_FOLD_DURATION_MS } from '@agent-device/contracts/device'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import type { ExecResult } from '@agent-device/host-kit/command'; import { recordActionEntry } from '../../../src/daemon/session-action-recorder.ts'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import { makeIosAppSession } from '../../../src/__tests__/test-utils/session-factories.ts'; @@ -34,6 +35,38 @@ afterEach(() => { fs.rmSync(isolatedHome, { recursive: true, force: true }); }); +/** Writes the `devicectl … displays --json-output ` result both fold fakes below answer. */ +function writeDisplayInventoryFixture(jsonOutputPath: string): void { + const displays = [0, 1].map((displayId) => ({ + name: `LCD-${displayId}`, + displayId, + nativeSize: [2007, 2853], + pointScale: 3, + type: { integrated: {} }, + active: displayId === 1, + })); + fs.writeFileSync(jsonOutputPath, JSON.stringify({ result: { displays } })); +} + +/** + * Answers the host-toolchain probe (`xcodebuild -version`, `sw_vers`, `uname -m`) the fold-helper + * build cache (`fold-helper-cache.ts`) reads before it builds or reuses a cached binary. Both fold + * fakes below route these calls through the same `runCommand`. + */ +function toolchainProbeAnswer(cmd: string, args: readonly string[]): ExecResult | undefined { + if (cmd === 'xcodebuild') + return { stdout: 'Xcode 16.4\nBuild version 16F6', stderr: '', exitCode: 0 }; + if (cmd === 'sw_vers') { + return { + stdout: args.includes('-buildVersion') ? '24G90' : '15.6', + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + return undefined; +} + test('timed fold keyframes reach simulator HID through the public client and daemon', async () => { const trajectory = [ { atMs: 0, angle: 0 }, @@ -53,18 +86,7 @@ test('timed fold keyframes reach simulator HID through the public client and dae devicectl: async (args) => { if (args.includes('hinge-angle')) return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; assert.ok(args.includes('displays')); - const displays = [0, 1].map((displayId) => ({ - name: `LCD-${displayId}`, - displayId, - nativeSize: [2007, 2853], - pointScale: 3, - type: { integrated: {} }, - active: displayId === 1, - })); - fs.writeFileSync( - args[args.indexOf('--json-output') + 1]!, - JSON.stringify({ result: { displays } }), - ); + writeDisplayInventoryFixture(args[args.indexOf('--json-output') + 1]!); return ok; }, }); @@ -74,17 +96,8 @@ test('timed fold keyframes reach simulator HID through the public client and dae appleToolProvider: () => ({ ...tool.provider, runCommand: async (command, args) => { - if (command === 'xcodebuild') { - return { stdout: 'Xcode 16.4\nBuild version 16F6', stderr: '', exitCode: 0 }; - } - if (command === 'sw_vers') { - return { - stdout: args.includes('-buildVersion') ? '24G90' : '15.6', - stderr: '', - exitCode: 0, - }; - } - if (command === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + const probeAnswer = toolchainProbeAnswer(command, args); + if (probeAnswer) return probeAnswer; assert.equal(command, 'xcrun'); assert.ok(args.includes('clang')); builds++; @@ -155,61 +168,81 @@ function createFoldLedgerAppleToolProvider(params: { const ledger: FoldLedgerCall[] = []; let hingeReads = 0; const ok = { stdout: '', stderr: '', exitCode: 0 }; + // Every real call on the fold route (packages/platform-apple/src/foldable/simulator-hid.ts, + // core/tool-provider.ts, core/simctl.ts) carries a bounded timeoutMs. A call reaching this fake + // with none is not a worst case the envelope assertion below can see, so it must fail the test + // rather than cost 0 virtual ms. const record = ( tool: FoldLedgerCall['tool'], args: readonly string[], options?: { timeoutMs?: number; kill?: { graceMs: number } }, ): void => { + assert.ok( + Number.isFinite(options?.timeoutMs) && options!.timeoutMs! > 0, + `fold ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, + ); const call: FoldLedgerCall = { tool, args, - timeoutMs: options?.timeoutMs ?? 0, + timeoutMs: options!.timeoutMs!, ...(options?.kill ? { graceMs: options.kill.graceMs } : {}), }; ledger.push(call); params.onCall(call); }; + // Built on the shared recording provider so any call this route does not script (macosHelper, + // macosHost, plist, or an unexpected runCommand) throws instead of being silently answered. + const recording = createRecordingAppleToolProvider({ + simctl: async (args, options) => { + record('simctl', args, options); + return ok; + }, + devicectl: async (args, options) => { + record('devicectl', args, options); + if (args.includes('hinge-angle')) { + const angle = params.hingeAngleAt(hingeReads); + hingeReads += 1; + return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; + } + assert.ok(args.includes('displays'), `unexpected devicectl call: ${args.join(' ')}`); + writeDisplayInventoryFixture(args[args.indexOf('--json-output') + 1]!); + return ok; + }, + }); const provider: AppleToolProvider = { - whichCommand: async () => true, + ...recording.provider, runCommand: async (cmd, args, options) => { - if (cmd === 'open' && args.join(' ') === '-a Simulator') return ok; record('runCommand', [cmd, ...args], options); + const probeAnswer = toolchainProbeAnswer(cmd, args); + if (probeAnswer) return probeAnswer; + assert.equal(cmd, 'xcrun', `unexpected runCommand call: ${cmd} ${args.join(' ')}`); + assert.ok(args.includes('clang'), `unexpected xcrun call: ${args.join(' ')}`); + fs.writeFileSync(args.at(-1)!, 'fold-helper-binary'); return ok; }, - simctl: { - run: async (args, options) => { - record('simctl', args, options); - return ok; - }, - }, - devicectl: { - run: async (args, options) => { - record('devicectl', args, options); - if (args.includes('hinge-angle')) { - const angle = params.hingeAngleAt(hingeReads); - hingeReads += 1; - return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; - } - assert.ok(args.includes('displays'), `unexpected devicectl call: ${args.join(' ')}`); - const displays = [0, 1].map((displayId) => ({ - name: `LCD-${displayId}`, - displayId, - nativeSize: [2007, 2853], - pointScale: 3, - type: { integrated: {} }, - active: displayId === 1, - })); - fs.writeFileSync( - args[args.indexOf('--json-output') + 1]!, - JSON.stringify({ result: { displays } }), - ); - return ok; - }, - }, }; return { provider, ledger, hingeReadCount: () => hingeReads }; } +/** + * Runs `fn` under its own throwaway `HOME`, so the fold-helper build cache under it + * (`~/.agent-device/fold-helper`) starts empty: the calibration and measured ledger runs each + * need a cold cache, not the outer per-test `HOME` the file's `beforeEach` already scoped, because + * a warm cache would skip the build phase and shrink the measured ledger. + */ +async function withColdFoldHelperCache(fn: () => Promise): Promise { + const outerHome = process.env.HOME; + const runHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-fold-run-home-')); + process.env.HOME = runHome; + try { + return await fn(); + } finally { + if (outerHome === undefined) delete process.env.HOME; + else process.env.HOME = outerHome; + fs.rmSync(runHome, { recursive: true, force: true }); + } +} + /** * Runs one cold fold through the public client and daemon against a fake Apple tool provider that * records `{ tool, args, timeoutMs, graceMs }` per call. When `withVirtualClock` is set, `Date.now` @@ -226,40 +259,42 @@ async function runFoldLedgerScenario(params: { hingeReadCount: number; virtualElapsedMs: number; }> { - const trajectory = [ - { atMs: 0, angle: 0 }, - { atMs: MAX_FOLD_DURATION_MS, angle: 100 }, - ]; - let virtualElapsedMs = 0; - const originNowMs = Date.now(); - const dateSpy = params.withVirtualClock - ? vi.spyOn(Date, 'now').mockImplementation(() => originNowMs + virtualElapsedMs) - : undefined; - const { provider, ledger, hingeReadCount } = createFoldLedgerAppleToolProvider({ - hingeAngleAt: params.hingeAngleAt, - onCall: (call) => { - virtualElapsedMs += Math.max(0, call.timeoutMs - 1) + (call.graceMs ?? 0); - }, - }); - const daemon = await createProviderScenarioHarness({ - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], - appleToolProvider: () => provider, - }); - daemon.setSession( - 'default', - makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), - ); - try { - const response = await daemon.callCommand('fold', [], { - platform: 'ios', - udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - keyframes: JSON.stringify(trajectory), + return withColdFoldHelperCache(async () => { + const trajectory = [ + { atMs: 0, angle: 0 }, + { atMs: MAX_FOLD_DURATION_MS, angle: 100 }, + ]; + let virtualElapsedMs = 0; + const originNowMs = Date.now(); + const dateSpy = params.withVirtualClock + ? vi.spyOn(Date, 'now').mockImplementation(() => originNowMs + virtualElapsedMs) + : undefined; + const { provider, ledger, hingeReadCount } = createFoldLedgerAppleToolProvider({ + hingeAngleAt: params.hingeAngleAt, + onCall: (call) => { + virtualElapsedMs += Math.max(0, call.timeoutMs - 1) + (call.graceMs ?? 0); + }, }); - return { response, ledger, hingeReadCount: hingeReadCount(), virtualElapsedMs }; - } finally { - dateSpy?.mockRestore(); - await daemon.close(); - } + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + appleToolProvider: () => provider, + }); + daemon.setSession( + 'default', + makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), + ); + try { + const response = await daemon.callCommand('fold', [], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + keyframes: JSON.stringify(trajectory), + }); + return { response, ledger, hingeReadCount: hingeReadCount(), virtualElapsedMs }; + } finally { + dateSpy?.mockRestore(); + await daemon.close(); + } + }); } function assertFoldLedgerPhaseCoverage(ledger: readonly FoldLedgerCall[]): void { From c5815897530bd4606ae0622799bc2e454d0092b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 17:45:37 +0200 Subject: [PATCH 4/4] test(ios): give the fold ledger test a cold-import budget The ledger test runs two cold folds through a fresh daemon each. After a platform-apple source change the first import is untransformed, and the test ran past vitest's 5 s default, which hid the ledger printout behind a timeout. Match the provider-scenario precedent of an explicit budget. --- test/integration/provider-scenarios/ios-fold.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 2e44e99ba7..9fcd044559 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -366,4 +366,4 @@ test('fold worst-case ledger covers the client envelope with the daemon-result m `fold ledger worst case (${measured.virtualElapsedMs}ms) + ${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms margin ` + `exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(measured.ledger, null, 2)}`, ); -}); +}, 20_000);