Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/command-registry/src/timeout-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -43,16 +48,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' },
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/command-descriptor-timeout-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,15 @@ 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<string, number | 'unbounded'> = {
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,
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',
Expand Down Expand Up @@ -318,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 }),
Expand Down
130 changes: 130 additions & 0 deletions src/daemon/handlers/__tests__/session-prepare.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@agent-device/device-selection/dispatch-resolve')>();
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<PlatformRuntimeOperations> {
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!);
});
4 changes: 2 additions & 2 deletions src/daemon/handlers/session-prepare.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading