diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 40260f3f27..d8a17a3355 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -103,6 +103,19 @@ export function resolveCommandRequestTimeoutMs( ); } +/** + * How long the daemon may work on a request before its client envelope expires, less the margin + * that lets the daemon's own result win that race. For a command with an enforced daemon deadline + * this is that deadline. + */ +export function resolveCommandDaemonBudgetMs( + policy: CommandTimeoutPolicy, + input: RequestTimeoutInput, +): number | undefined { + const envelopeMs = resolveCommandRequestTimeoutMs(policy, input); + return envelopeMs === undefined ? undefined : envelopeMs - REQUEST_TIMEOUT_BUDGET_MARGIN_MS; +} + /** Resolves an enforced daemon request deadline, when the command declares one. */ export function resolveCommandDaemonTimeoutMs( policy: CommandTimeoutPolicy, diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 243ee02451..de32838800 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -33,6 +33,12 @@ export type ApplicationLifecycleExecution = Readonly<{ * means the platform's default boot wait; `prepare` derives its own deadline from `timeoutMs`. */ startupDeadlineAtMs?: number; + /** + * Absolute time by which the request must have its result: its enforced daemon deadline, or its + * admission plus what its client envelope leaves the daemon. Absent for a request no transport + * admitted. + */ + requestDeadlineAtMs?: number; requestId?: string; logPath?: string; traceLogPath?: string; diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index fbbe12988c..9a91a01f05 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -11,8 +11,12 @@ import { resolveCommandDeviceClaimPolicy, resolveCommandTimeoutPolicy, } from '@agent-device/command-registry/registry'; -import { resolveCommandDaemonTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { + resolveCommandDaemonBudgetMs, + resolveCommandDaemonTimeoutMs, +} from '@agent-device/command-registry/timeout-policy'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonRequest } from './daemon-request.ts'; export type { DaemonCommandDescriptor, DaemonCommandRoute, SessionCommandKind }; @@ -37,6 +41,19 @@ export function resolveDaemonRequestTimeoutMs(req: DaemonRequest): number | unde return resolveCommandDaemonTimeoutMs(resolveCommandTimeoutPolicy(req.command), req); } +/** + * When a request admitted now must have its result: the client envelope starts as the transport + * admits the request, and the daemon keeps the envelope's result margin. `undefined` for a command + * whose client waits without an envelope. + */ +export function resolveDaemonRequestResultDeadlineMs( + req: DaemonWireRequest, + admittedAtMs = Date.now(), +): number | undefined { + const budgetMs = resolveCommandDaemonBudgetMs(resolveCommandTimeoutPolicy(req.command), req); + return budgetMs === undefined ? undefined : admittedAtMs + budgetMs; +} + export function getDaemonCommandRoute(command: string): DaemonCommandRoute { return getDaemonCommandDescriptor(command)?.route ?? 'generic'; } diff --git a/packages/daemon/src/server/__tests__/http-server-rpc-lifetime.test.ts b/packages/daemon/src/server/__tests__/http-server-rpc-lifetime.test.ts index 1051e1b567..f558a49d6b 100644 --- a/packages/daemon/src/server/__tests__/http-server-rpc-lifetime.test.ts +++ b/packages/daemon/src/server/__tests__/http-server-rpc-lifetime.test.ts @@ -3,7 +3,8 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { closeLoopbackServer, skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; -import { postRpcCommand, startRpcServer } from './http-server-rpc.fixtures.ts'; +import { postRpcCommand, postRpcRaw, startRpcServer } from './http-server-rpc.fixtures.ts'; +import { applicationLifecycleExecutionFromRequest } from '../../sessions/application-lifecycle-execution.ts'; const TOKEN = 'lifetime-token'; @@ -33,3 +34,31 @@ test('an rpc request registers its tracking id while in flight and releases it o await closeLoopbackServer(server); } }); + +test('an admitted open records a result deadline of its client envelope less the daemon result margin', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let deadlineAtMs: number | undefined; + const { port, server } = await startRpcServer(TOKEN, async (request) => { + deadlineAtMs = applicationLifecycleExecutionFromRequest( + request, + '/tmp/request.log', + ).requestDeadlineAtMs; + return { ok: true, data: {} } satisfies DaemonResponse; + }); + try { + const sentAtMs = Date.now(); + const requestId = 'req-open-result-deadline'; + const body = JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method: 'agent_device.command', + params: { command: 'open', positionals: ['com.example.app'], meta: { requestId } }, + }); + assert.equal((await postRpcRaw(port, TOKEN, body)).status, 200); + const answeredAtMs = Date.now(); + assert.ok(deadlineAtMs !== undefined, 'the open must carry a deadline'); + assert.ok(deadlineAtMs >= sentAtMs + 60_000 && deadlineAtMs <= answeredAtMs + 60_000); + } finally { + await closeLoopbackServer(server); + } +}); diff --git a/packages/daemon/src/server/http/http-server.ts b/packages/daemon/src/server/http/http-server.ts index dc0e8cfb13..12d7893ff1 100644 --- a/packages/daemon/src/server/http/http-server.ts +++ b/packages/daemon/src/server/http/http-server.ts @@ -14,6 +14,7 @@ import { normalizeError } from '@agent-device/kernel/errors'; import { LEASE_BACKENDS } from '@agent-device/kernel/contracts'; import { isRecord } from '@agent-device/kernel/record'; import type { DaemonInvokeFn, DaemonRequest } from '../../core/daemon-request.ts'; +import { resolveDaemonRequestResultDeadlineMs } from '../../core/daemon-command-registry.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { clearRequestAbortRegistration, @@ -472,7 +473,9 @@ function beginRpcRequestLifetime(params: { return { admit(daemonRequest) { requestId = resolveRequestTrackingId(daemonRequest.meta?.requestId, rpcId); - abortRegistration = registerRequestAbort(requestId); + abortRegistration = registerRequestAbort(requestId, undefined, { + resultDeadlineMs: resolveDaemonRequestResultDeadlineMs(daemonRequest), + }); return { ...daemonRequest, meta: { ...daemonRequest.meta, requestId }, diff --git a/packages/daemon/src/server/http/transport.test.ts b/packages/daemon/src/server/http/transport.test.ts index 15ffba5348..509ba0850a 100644 --- a/packages/daemon/src/server/http/transport.test.ts +++ b/packages/daemon/src/server/http/transport.test.ts @@ -5,7 +5,12 @@ import http from 'node:http'; import net from 'node:net'; import { test } from 'vitest'; import { createSocketServer, listenHttpServer, listenNetServer } from './transport.ts'; -import { getRequestSignal } from '@agent-device/host-kit/request'; +import { + clearRequestAbortRegistration, + getRequestSignal, + registerRequestAbort, +} from '@agent-device/host-kit/request'; +import { applicationLifecycleExecutionFromRequest } from '../../sessions/application-lifecycle-execution.ts'; import { closeLoopbackServer, skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; test('Provider-backed integration daemon socket transport frames requests and normalizes malformed input', async (t) => { @@ -160,6 +165,90 @@ test('socket disconnect cancels only the disconnected connection in-flight reque } }); +/** The deadline an `open` running under `requestId` would carry. */ +function openDeadlineAtMs(requestId: string | undefined): number | undefined { + return applicationLifecycleExecutionFromRequest( + { + token: 'provider-scenario-token', + session: 'default', + command: 'open', + positionals: ['com.example.app'], + meta: { requestId }, + }, + '/tmp/request.log', + ).requestDeadlineAtMs; +} + +test('a socket-admitted open --timeout moves its deadline with the envelope it widens', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let deadlineAtMs: number | undefined; + const server = createSocketServer(async (req): Promise => { + deadlineAtMs = applicationLifecycleExecutionFromRequest( + req, + '/tmp/request.log', + ).requestDeadlineAtMs; + return { ok: true, data: {} }; + }); + try { + const client = await connectSocket(await listenNetServer(server)); + const sentAtMs = Date.now(); + await writeSocketRequests( + client, + [ + JSON.stringify({ + token: 'provider-scenario-token', + session: 'default', + command: 'open', + positionals: ['com.example.app'], + flags: { timeoutMs: 120_000 }, + meta: { requestId: 'socket-open-widened-deadline' }, + }), + ], + 1, + ); + const answeredAtMs = Date.now(); + client.destroy(); + assert.ok(deadlineAtMs !== undefined, 'the open must carry a deadline'); + assert.ok(deadlineAtMs >= sentAtMs + 120_000 && deadlineAtMs <= answeredAtMs + 120_000); + } finally { + await closeLoopbackServer(server); + } +}); + +test('an attempt nested under a test request without a client envelope has no deadline', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let attemptDeadlineAtMs: number | undefined = Number.NaN; + const server = createSocketServer(async (req): Promise => { + const registration = registerRequestAbort('socket-test-attempt', req.meta?.requestId); + try { + attemptDeadlineAtMs = openDeadlineAtMs('socket-test-attempt'); + } finally { + clearRequestAbortRegistration(registration); + } + return { ok: true, data: {} }; + }); + try { + const client = await connectSocket(await listenNetServer(server)); + await writeSocketRequests( + client, + [ + JSON.stringify({ + token: 'provider-scenario-token', + session: 'default', + command: 'test', + positionals: ['flows'], + meta: { requestId: 'socket-test-suite' }, + }), + ], + 1, + ); + client.destroy(); + assert.equal(attemptDeadlineAtMs, undefined); + } finally { + await closeLoopbackServer(server); + } +}); + async function waitForAbort(signal: AbortSignal): Promise { if (signal.aborted) return; await new Promise((resolve) => { @@ -181,6 +270,7 @@ async function connectSocket(port: number): Promise { async function writeSocketRequests( socket: net.Socket, lines: string[], + expectedResponses = 2, ): Promise> { return await new Promise((resolve, reject) => { const responses: Array<{ ok?: boolean; data?: any; error?: any }> = []; @@ -195,7 +285,7 @@ async function writeSocketRequests( if (line) { responses.push(JSON.parse(line)); } - if (responses.length === 2) { + if (responses.length === expectedResponses) { resolve(responses); return; } diff --git a/packages/daemon/src/server/http/transport.ts b/packages/daemon/src/server/http/transport.ts index 83a5dbcb0a..bb7a409bec 100644 --- a/packages/daemon/src/server/http/transport.ts +++ b/packages/daemon/src/server/http/transport.ts @@ -4,6 +4,7 @@ import { AsyncResource } from 'node:async_hooks'; import net from 'node:net'; import type { Server as HttpServer } from 'node:http'; import type { DaemonInvokeFn, DaemonRequest } from '../../core/daemon-request.ts'; +import { resolveDaemonRequestResultDeadlineMs } from '../../core/daemon-command-registry.ts'; import { clearRequestAbortRegistration, isRequestCanceled, @@ -76,7 +77,9 @@ export function createSocketServer(handleRequest: DaemonInvokeFn): DaemonServer ...req.meta, requestId: requestIdForCleanup, }; - requestAbortRegistration = registerRequestAbort(requestIdForCleanup); + requestAbortRegistration = registerRequestAbort(requestIdForCleanup, undefined, { + resultDeadlineMs: resolveDaemonRequestResultDeadlineMs(req), + }); activeRequestIds.add(requestIdForCleanup); if (isRequestCanceled(requestIdForCleanup)) { throw createRequestCanceledError(); diff --git a/packages/daemon/src/sessions/application-lifecycle-execution.test.ts b/packages/daemon/src/sessions/application-lifecycle-execution.test.ts new file mode 100644 index 0000000000..ed08252d26 --- /dev/null +++ b/packages/daemon/src/sessions/application-lifecycle-execution.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { withRequestDeadline } from '@agent-device/host-kit/request'; +import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; +import { applicationLifecycleExecutionFromRequest } from './application-lifecycle-execution.ts'; + +const NOW_MS = 1_000_000; + +afterEach(() => { + vi.useRealTimers(); +}); + +function openExecution(requestId: string | undefined): ApplicationLifecycleExecution { + return applicationLifecycleExecutionFromRequest( + { + token: 'token', + session: 'default', + command: 'open', + positionals: ['com.example.app'], + meta: { requestId }, + }, + '/tmp/request.log', + ); +} + +test('an enforced request deadline is the deadline', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW_MS); + let execution: ApplicationLifecycleExecution | undefined; + await withRequestDeadline('open-in-bounded-batch', 20_000, async () => { + execution = openExecution('open-in-bounded-batch'); + return { ok: true, data: {} }; + }); + expect(execution?.requestDeadlineAtMs).toBe(NOW_MS + 20_000); +}); + +test('a request no transport admitted has no deadline', () => { + expect(openExecution(undefined).requestDeadlineAtMs).toBeUndefined(); + expect(openExecution('never-admitted').requestDeadlineAtMs).toBeUndefined(); +}); diff --git a/packages/daemon/src/sessions/application-lifecycle-execution.ts b/packages/daemon/src/sessions/application-lifecycle-execution.ts index df269ddf63..2e7d18699a 100644 --- a/packages/daemon/src/sessions/application-lifecycle-execution.ts +++ b/packages/daemon/src/sessions/application-lifecycle-execution.ts @@ -1,3 +1,7 @@ +import { + getRequestExecutionDeadlineMs, + getRequestResultDeadlineMs, +} from '@agent-device/host-kit/request'; import { resolvePlannedOperations } from '../core/execution-plan.ts'; import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; import { resolveRunnerLogicalLeaseContext } from '../core/lease-context.ts'; @@ -10,6 +14,7 @@ export function applicationLifecycleExecutionFromRequest( traceLogPath?: string, ): ApplicationLifecycleExecution { return { + requestDeadlineAtMs: requestDeadlineAtMs(req), requestId: req.meta?.requestId, logPath, traceLogPath, @@ -25,3 +30,12 @@ export function applicationLifecycleExecutionFromRequest( plannedOperations: resolvePlannedOperations(req.internal?.executionPlan), }; } + +/** + * An enforced daemon deadline is the request's deadline. Otherwise it is the result deadline the + * transport that admitted the request (or its parent) set, ahead of the client's envelope. + */ +function requestDeadlineAtMs(req: DaemonRequest): number | undefined { + const requestId = req.meta?.requestId; + return getRequestExecutionDeadlineMs(requestId) ?? getRequestResultDeadlineMs(requestId); +} diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 807c4811bb..2abf1ecebc 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -12,7 +12,15 @@ type RequestCancelState = { requestAbortControllers: Map; }; -type RequestAbortController = AbortController & { executionDeadlineMs?: number }; +type RequestAbortController = AbortController & { + executionDeadlineMs?: number; + /** + * The daemon's own result deadline, which the transport that admitted the request set ahead of + * the client's: the end of the client envelope less the daemon's result margin. An attempt + * registered under a parent inherits the parent's, because it runs inside the parent's envelope. + */ + resultDeadlineMs?: number; +}; const REQUEST_CANCEL_SLOT = Symbol('requestCancel'); const requestCancelState = (): RequestCancelState => @@ -80,6 +88,7 @@ function evictOldestSetEntries(set: Set): void { export function registerRequestAbort( requestId: string | undefined, parentRequestId?: string, + admission?: Readonly<{ resultDeadlineMs?: number }>, ): RequestAbortRegistration | undefined { if (!requestId) return undefined; if (requestAbortControllers().has(requestId)) { @@ -90,9 +99,11 @@ export function registerRequestAbort( } evictOldestEntries(requestAbortControllers()); const controller: RequestAbortController = new AbortController(); - controller.executionDeadlineMs = parentRequestId - ? requestAbortControllers().get(parentRequestId)?.executionDeadlineMs - : undefined; + const parent = parentRequestId ? requestAbortControllers().get(parentRequestId) : undefined; + controller.executionDeadlineMs = parent?.executionDeadlineMs; + controller.resultDeadlineMs = parentRequestId + ? parent?.resultDeadlineMs + : admission?.resultDeadlineMs; requestAbortControllers().set(requestId, controller); if (canceledRequestIds().has(requestId)) { controller.abort(createRequestCanceledError()); @@ -154,6 +165,11 @@ export function getRequestExecutionDeadlineMs(requestId: string | undefined): nu return requestId ? requestAbortControllers().get(requestId)?.executionDeadlineMs : undefined; } +/** The daemon's result deadline the admitting transport set for the request, if it set one. */ +export function getRequestResultDeadlineMs(requestId: string | undefined): number | undefined { + return requestId ? requestAbortControllers().get(requestId)?.resultDeadlineMs : undefined; +} + export function throwIfRequestCanceled(requestId: string | undefined): void { getRequestSignal(requestId)?.throwIfAborted(); if (isRequestCanceled(requestId)) { diff --git a/packages/host-kit/src/request.ts b/packages/host-kit/src/request.ts index 492c044265..a0bc4cebc9 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -4,6 +4,7 @@ export { clearRequestCanceled, getRequestSignal, getRequestExecutionDeadlineMs, + getRequestResultDeadlineMs, isRequestCanceled, markRequestCanceled, registerRequestAbort, diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 9289556651..e3b73d09a6 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; import { awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner-client.ts'; import type { Interactor } from '@agent-device/contracts/interactor-types'; @@ -19,6 +20,10 @@ import { URL_OWNER_LOOKUP_TIMEOUT_MS, } from './launch-confirmation.ts'; import { alertNotFound, CONFIRMATION } from './launch-confirmation.fixtures.ts'; +import { + buildRunnerResponseError, + MAIN_THREAD_TIMEOUT_RUNNER_CODE, +} from './runner/runner-contract.ts'; vi.mock('./core/runner-client.ts', () => ({ runAppleRunnerCommand: vi.fn() })); @@ -55,6 +60,7 @@ function port( legs: Readonly<{ resolveUrlOwner?: () => Promise; acceptAlert?: () => Promise; + deadlineAtMs?: number; }> = {}, ) { const acceptAlert = vi.fn(legs.acceptAlert ?? (async () => ({}))); @@ -65,6 +71,7 @@ function port( readAlert: vi.fn(readAlert), acceptAlert, resolveUrlOwner, + deadlineAtMs: legs.deadlineAtMs, }; return { port: value, acceptAlert, resolveUrlOwner }; } @@ -286,6 +293,7 @@ test.each([ { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), new AbortController().signal, + undefined, ).then((value) => (answered = value)), ); @@ -326,6 +334,7 @@ test('a hung URL owner lookup leaves the open unanswered within the launch budge { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), new AbortController().signal, + undefined, ).then((value) => (answered = value)), ); @@ -353,6 +362,7 @@ test.each([ { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), open.signal, + undefined, ), ); @@ -378,6 +388,7 @@ test('the launch confirmation waits for a system prompt that is still appearing' { url: 'example://automation', appBundleId: 'com.example.app' }, interactor, new AbortController().signal, + undefined, ); const pending = device.awaitAlert(); await vi.advanceTimersByTimeAsync(500); @@ -403,6 +414,7 @@ test('an expired confirmation wait retains typed absence instead of an unreadabl acceptAlert: vi.fn(), }, new AbortController().signal, + undefined, ); const pending = answerLaunchConfirmation(device); await vi.advanceTimersByTimeAsync(11_000); @@ -437,6 +449,7 @@ test('a failed accept checks presence once without waiting for the dismissed pro { url: 'example://automation', appBundleId: 'com.example.app' }, interactor, new AbortController().signal, + undefined, ); let settled: LaunchConfirmationAttempt | undefined; const pending = answerLaunchConfirmation(device).then((value) => { @@ -470,6 +483,7 @@ test('a no-prompt presentation probe completes within a few seconds', async () = { url: 'example://automation', appBundleId: 'com.example.app' }, interactor, new AbortController().signal, + undefined, ); let settled: LaunchConfirmationAttempt | undefined; const pending = answerLaunchConfirmation(device).then((value) => { @@ -500,6 +514,7 @@ test('the port reads and accepts the alert for the session app and asks the sche { url: 'example://automation', appBundleId: 'com.example.app' }, interactor, signal, + undefined, ); await device.awaitAlert(); await device.readAlert(); @@ -532,6 +547,7 @@ test('the port reads a typed absence as no alert and keeps any other failure', a { url: 'example://automation', appBundleId: 'com.example.app' }, interactor, new AbortController().signal, + undefined, ); await expect(device.awaitAlert()).resolves.toBeUndefined(); @@ -545,6 +561,7 @@ test('a runner that cannot be resolved reports an unreadable attempt', async () { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.reject(new AppError('COMMAND_FAILED', 'xcrun timed out', { timeoutMs: 10_000 })), new AbortController().signal, + undefined, ), ).resolves.toEqual({ outcome: 'unreadable', step: 'runner' }); }); @@ -579,3 +596,147 @@ test('a matching launch title with other buttons is left unanswered on the first expect(device.awaitAlert).toHaveBeenCalledOnce(); expect(acceptAlert).not.toHaveBeenCalled(); }); + +/** The accept as the runner answers it when its main-thread watchdog abandons the press (#3861). */ +function runnerMainThreadTimeout(): AppError { + return buildRunnerResponseError({ + ok: false, + error: { code: MAIN_THREAD_TIMEOUT_RUNNER_CODE, message: 'main thread execution timed out' }, + }); +} + +const STILL_PRESENT = { outcome: 'unanswered', reason: 'alert-still-present' }; + +/** + * One more press costs its preflight and press watchdog, then the verification read's preflight + * and watchdog: 1 s + 10 s + 1 s + 10 s. + */ +const NEXT_PRESS_COST_MS = 22_000; + +/** Runs `body` on a fake clock, so each leg spends exactly the time it advances. */ +async function onFakeClock(body: () => Promise): Promise { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + await body(); + } finally { + vi.useRealTimers(); + } +} + +/** An accept the runner watchdog abandons after `spentMs` of preflight and press. */ +function abandonedPress(spentMs: number = DEFAULT_ALERT_TIMEOUT_MS) { + return vi.fn(async () => { + vi.advanceTimersByTime(spentMs); + throw runnerMainThreadTimeout(); + }); +} + +test('an accept the runner watchdog abandoned is pressed again while the request has time for it', async () => { + const acceptAlert = vi + .fn<() => Promise>() + .mockRejectedValueOnce(runnerMainThreadTimeout()) + .mockResolvedValueOnce({}); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 60_000, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ outcome: 'accepted' }); + expect(acceptAlert).toHaveBeenCalledTimes(2); +}); + +test('an abandoned accept is not pressed again when the request has no deadline', async () => { + const acceptAlert = vi.fn(async () => { + throw runnerMainThreadTimeout(); + }); + const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); +}); + +test('the watchdog message without the runner code is not pressed again', async () => { + const acceptAlert = vi.fn(async () => { + throw new AppError('COMMAND_FAILED', 'main thread execution timed out'); + }); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 60_000, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); +}); + +test('an abandoned accept that removed the confirmation is not pressed again', async () => { + const acceptAlert = vi.fn(async () => { + throw runnerMainThreadTimeout(); + }); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 60_000, + }); + vi.mocked(device.readAlert).mockResolvedValue(undefined); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ + outcome: 'unreadable', + step: 'alert-accept', + }); + expect(acceptAlert).toHaveBeenCalledOnce(); +}); + +test('a request budget mostly spent before the first press keeps the existing failure', async () => { + await onFakeClock(async () => { + const acceptAlert = abandonedPress(); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 15_000, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); + }); +}); + +test('a delayed preflight leaves too little time for another press', async () => { + await onFakeClock(async () => { + const acceptAlert = abandonedPress(800 + DEFAULT_ALERT_TIMEOUT_MS); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 800 + DEFAULT_ALERT_TIMEOUT_MS + NEXT_PRESS_COST_MS - 1, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); + }); +}); + +test('a delayed verification read leaves too little time for another press', async () => { + await onFakeClock(async () => { + const acceptAlert = abandonedPress(); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + DEFAULT_ALERT_TIMEOUT_MS + 4_000 + NEXT_PRESS_COST_MS - 1, + }); + vi.mocked(device.readAlert).mockImplementation(async () => { + vi.advanceTimersByTime(4_000); + return CONFIRMATION; + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); + }); +}); + +test('abandoned accepts stop once the request cannot hold another press', async () => { + await onFakeClock(async () => { + const acceptAlert = abandonedPress(); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + DEFAULT_ALERT_TIMEOUT_MS + NEXT_PRESS_COST_MS, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index ce289ab5c8..877a1c68e5 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -1,3 +1,4 @@ +import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -5,6 +6,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { alertIfPresent } from './alert.ts'; import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts'; +import { RUNNER_READY_PREFLIGHT_TIMEOUT_MS } from './runner/runner-contract.ts'; +import { isRunnerMainThreadTimeoutError } from './runner/runner-error-classification.ts'; export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app'; @@ -46,12 +49,23 @@ const LAUNCH_CONFIRMATION_TITLE = /^Open in [“"].+[”"]\?$/u; */ export const URL_OWNER_LOOKUP_TIMEOUT_MS = 10_000; +/** + * What one more press of `Open` can cost (#3861): the press and the read that verifies it, each a + * runner alert command that pays a readiness preflight and then runs up to its alert watchdog. + */ +const NEXT_PRESS_COST_MS = 2 * (RUNNER_READY_PREFLIGHT_TIMEOUT_MS + DEFAULT_ALERT_TIMEOUT_MS); + /** A custom-scheme launch URL SpringBoard may hold for the session app it was checked for. */ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: string }>; /** The device reads and the answer one launch confirmation needs, bound to the session app. */ export type LaunchConfirmationPort = Readonly<{ appBundleId: string; + /** + * When the enclosing request must have its result. It decides only whether `Open` is pressed + * again; the first press and every read keep their own runner bounds. + */ + deadlineAtMs: number | undefined; /** Initial presentation wait, or `undefined` when no alert appears within its window. */ awaitAlert(): Promise | undefined>; /** One presence read after an attempted accept. */ @@ -101,9 +115,44 @@ export async function answerLaunchConfirmation( hint: `iOS is asking whether to open ${owner.owner}. Answer it with alert accept or alert dismiss, and pass a launch URL whose scheme belongs to the session app.`, }); } - return await port - .acceptAlert() - .then(accepted, async (error: unknown) => await verifyFailedAcceptance(port, alert, error)); + return await acceptConfirmation(port, alert); +} + +/** + * Presses `Open` and verifies a failed press. Only a press the runner's watchdog abandoned, with the + * same confirmation still up, is pressed again, and only while the request's deadline leaves time + * for another whole press and its verification read. Any other failure, and a confirmation that is + * gone, ends the answer at once. The runner refuses new work as `RUNNER_BUSY` until abandoned work + * drains, so a read that sees the confirmation proves the abandoned press finished without + * answering it, and the next press cannot answer a later alert. + */ +async function acceptConfirmation( + port: LaunchConfirmationPort, + original: Record, +): Promise { + for (;;) { + const failure = await port.acceptAlert().then( + () => undefined, + (error: unknown) => ({ error }), + ); + if (!failure) return accepted(); + const attempt = await verifyFailedAcceptance(port, original, failure.error); + if (attempt.outcome !== 'unanswered') return attempt; + const remainingMs = timeLeft(port.deadlineAtMs); + if (!isRunnerMainThreadTimeoutError(failure.error) || remainingMs < NEXT_PRESS_COST_MS) { + reportUnanswered('alert-still-present', {}); + return attempt; + } + emitDiagnostic({ + level: 'debug', + phase: 'ios_launch_confirmation_reaccept', + data: { reason: 'runner-main-thread-timeout', remainingMs }, + }); + } +} + +function timeLeft(deadlineAtMs: number | undefined): number { + return deadlineAtMs === undefined ? 0 : Math.max(0, deadlineAtMs - Date.now()); } async function verifyFailedAcceptance( @@ -118,7 +167,6 @@ async function verifyFailedAcceptance( isLaunchConfirmation(read.alert) && read.alert['message'] === original['message'] ) { - reportUnanswered('alert-still-present', {}); return { outcome: 'unanswered', reason: 'alert-still-present' }; } return unreadable('alert-accept')(error); @@ -181,12 +229,13 @@ export async function answerSimulatorLaunchConfirmation( confirmation: LaunchConfirmationTarget, interactor: Promise, signal: AbortSignal, + deadlineAtMs: number | undefined, ): Promise { const settled: Readonly<{ interactor: Interactor }> | LaunchConfirmationAttempt = await interactor.then((resolved) => ({ interactor: resolved }), unreadable('runner')); if ('outcome' in settled) return settled; return await answerLaunchConfirmation( - createLaunchConfirmationPort(device, confirmation, settled.interactor, signal), + createLaunchConfirmationPort(device, confirmation, settled.interactor, signal, deadlineAtMs), ); } @@ -195,6 +244,7 @@ export function createLaunchConfirmationPort( { url, appBundleId }: LaunchConfirmationTarget, interactor: Pick, signal: AbortSignal, + deadlineAtMs: number | undefined, ): LaunchConfirmationPort { const awaitAlert = interactor.awaitAlert?.bind(interactor); const readAlert = interactor.readAlert?.bind(interactor); @@ -205,6 +255,7 @@ export function createLaunchConfirmationPort( const target = { appBundleId, surface: 'app' } as const; return Object.freeze({ appBundleId, + deadlineAtMs, awaitAlert: async () => await alertIfPresent( awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index c394c9ad8e..45ab3cefe8 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -327,6 +327,7 @@ function confirmationAnswer( confirmation, binding.resolveInteractor(input.execution, confirmation.appBundleId), binding.signal, + input.execution.requestDeadlineAtMs, ); }; } diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index 575b46aacb..54572a600d 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -198,7 +198,8 @@ export async function settleAppleOpen( * process is a launch that was lost, so the URL is handed over again and read once more. * * Every step keeps the bound it already had: `openurl` at `IOS_SIMULATOR_OPENURL_TIMEOUT_MS`, the - * read and accept at the runner's alert timeouts, and the observation at the discovery deadline and + * read and accept at the runner's alert timeouts (an accept the runner watchdog abandoned is pressed + * again only while the request's deadline holds another press and its read), and the observation at the discovery deadline and * the bridge's own launch-transition windows. Nothing here adds an unbounded wait. */ async function answerHeldLaunch( diff --git a/packages/platform-apple/src/runner/__tests__/index-launch-confirmation.test.ts b/packages/platform-apple/src/runner/__tests__/index-launch-confirmation.test.ts new file mode 100644 index 0000000000..359332c09c --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/index-launch-confirmation.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import type { RunnerSession } from '../runner-session.ts'; +import { RunnerCommandAccounting } from '../runner-session-types.ts'; +import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; +import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; + +/** + * The launch-confirmation answer over the real Apple alert legs and the real runner client: command + * ids, readiness preflights, the read-only `RUNNER_BUSY` resend and the transport all run against a + * fake runner. Only session creation and the URL-owner lookup are faked. A controlled clock charges + * the abandoned press its watchdog and each busy resend its production backoff, so the re-press gate + * sees the time the real route would have spent. + */ + +const clock = vi.hoisted(() => ({ offsetMs: 0, acceptsSent: 0 })); +const { ensureRunnerSessionMock, readRunnerSessionLivenessMock, emitDiagnosticMock } = vi.hoisted( + () => ({ + ensureRunnerSessionMock: vi.fn(), + readRunnerSessionLivenessMock: vi.fn(), + emitDiagnosticMock: vi.fn(), + }), +); + +vi.mock('../runner-session.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureRunnerSession: ensureRunnerSessionMock, + readRunnerSessionLiveness: readRunnerSessionLivenessMock, + }; +}); + +vi.mock('../runner-transport.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendRunnerCommandOnce: async (...args: Parameters) => { + const response = await actual.sendRunnerCommandOnce(...args); + const command = args[2]; + // The first press is the one the runner watchdog abandons: it spends the whole watchdog. + if (command.command === 'alert' && command.action === 'accept' && clock.acceptsSent++ === 0) { + clock.offsetMs += DEFAULT_ALERT_TIMEOUT_MS; + } + return response; + }, + }; +}); + +// The alert legs reach the runner through the package's composition root; route it to the runner +// client under test, which this suite's host setup binds. +vi.mock('../../core/runner-client.ts', async () => { + const runnerClient = await import('../runner-client.ts'); + return { runAppleRunnerCommand: runnerClient.runAppleRunnerCommand }; +}); + +vi.mock('../../core/app-resolution.ts', () => ({ + resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => 'com.example.app'), +})); + +vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { + const actual = await importOriginal(); + emitDiagnosticMock.mockImplementation(actual.emitDiagnostic); + return { ...actual, emitDiagnostic: emitDiagnosticMock }; +}); + +const { actOnAppleAlert, awaitAppleAlert, readAppleAlert } = await import('../../alert.ts'); +const { answerSimulatorLaunchConfirmation } = await import('../../launch-confirmation.ts'); + +const NOW_MS = 1_000_000; +/** What the daemon gives a default `open`: its 90 s client envelope less the 30 s result margin. */ +const OPEN_DEADLINE_MS = 60_000; +const CONFIRMATION = { message: 'Open in “Example App”?', items: ['Cancel', 'Open'] }; +let server: FakeRunnerServer | undefined; + +beforeEach(() => { + clock.offsetMs = 0; + clock.acceptsSent = 0; + resetRunnerRecycleLedgerForTests(); + vi.spyOn(Date, 'now').mockImplementation(() => NOW_MS + clock.offsetMs); + const { retryWithPolicy } = appleRunnerTestHost.defaults(); + appleRunnerTestHost.update({ + // Each resend is charged its production backoff on the controlled clock, then waits 1 ms. + retryWithPolicy: (task, policy, options) => { + let attempt = 0; + return retryWithPolicy( + async (context) => { + if (attempt > 0) { + const baseDelayMs = policy?.baseDelayMs ?? 0; + clock.offsetMs += Math.min( + baseDelayMs * 2 ** (attempt - 1), + policy?.maxDelayMs ?? Infinity, + ); + } + attempt += 1; + return await task(context); + }, + { ...policy, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 }, + options, + ); + }, + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + emitDiagnosticMock.mockClear(); + ensureRunnerSessionMock.mockReset(); + readRunnerSessionLivenessMock.mockReset(); + await server?.close(); + server = undefined; +}); + +function seedSession(port: number): void { + const session: RunnerSession = { + sessionId: `launch-confirmation:${port}`, + device: IOS_SIMULATOR, + deviceId: IOS_SIMULATOR.id, + endpoint: { port, token: 'runner-token-fixture' }, + xctestrunPath: '/tmp/launch-confirmation.xctestrun', + jsonPath: '/tmp/launch-confirmation.json', + testPromise: new Promise(() => {}), + child: { pid: process.pid, exitCode: null }, + state: 'ready', + commandCharges: new RunnerCommandAccounting(), + }; + ensureRunnerSessionMock.mockResolvedValue(session); + // A live session, or a resend reads as a recycle boot and charges the request's recycle budget. + readRunnerSessionLivenessMock.mockReturnValue({ + sessionId: session.sessionId, + liveness: 'ready', + }); +} + +async function answer(deadlineAtMs: number) { + const interactor = { + awaitAlert: async (options) => await awaitAppleAlert(IOS_SIMULATOR, {}, options, {}), + readAlert: async (options) => await readAppleAlert(IOS_SIMULATOR, {}, options, {}), + acceptAlert: async (options) => await actOnAppleAlert(IOS_SIMULATOR, {}, 'accept', options, {}), + } satisfies Pick; + return await answerSimulatorLaunchConfirmation( + IOS_SIMULATOR, + { url: 'example://automation', appBundleId: 'com.example.app' }, + Promise.resolve(interactor as Interactor), + new AbortController().signal, + deadlineAtMs, + ); +} + +function sentAlertActions(): unknown[] { + return (server?.requests ?? []) + .filter(({ command }) => command === 'alert') + .map(({ body }) => body.action); +} + +test('an abandoned press is verified through RUNNER_BUSY resends and pressed again within the open deadline', async () => { + server = await startFakeRunnerServer({ + alert: [ + { kind: 'ok', data: CONFIRMATION }, + { + kind: 'runnerError', + code: 'MAIN_THREAD_TIMEOUT', + message: 'main thread execution timed out', + }, + { kind: 'runnerError', code: 'RUNNER_BUSY', message: 'runner busy' }, + { kind: 'runnerError', code: 'RUNNER_BUSY', message: 'runner busy' }, + { kind: 'ok', data: CONFIRMATION }, + { kind: 'ok', data: {} }, + ], + }); + seedSession(server.port); + + assert.deepEqual(await answer(NOW_MS + OPEN_DEADLINE_MS), { outcome: 'accepted' }); + assert.deepEqual(sentAlertActions(), ['get', 'accept', 'get', 'get', 'get', 'accept']); + const reaccept = emitDiagnosticMock.mock.calls + .map(([event]) => event) + .find((event) => event.phase === 'ios_launch_confirmation_reaccept'); + // The watchdog (10 s) and two busy resends (200 ms + 400 ms) leave 49.4 s, well over the 22 s a + // whole press and its read can cost. + assert.equal(reaccept?.data?.remainingMs, OPEN_DEADLINE_MS - DEFAULT_ALERT_TIMEOUT_MS - 600); +}); + +test('a first press is sent with its full watchdog even with only 5 s of the open left', async () => { + server = await startFakeRunnerServer({ + alert: [ + { kind: 'ok', data: CONFIRMATION }, + { kind: 'ok', data: {} }, + ], + }); + seedSession(server.port); + + assert.deepEqual(await answer(NOW_MS + 5_000), { outcome: 'accepted' }); + const accept = server.requests.find(({ body }) => body.action === 'accept'); + assert.equal(accept?.body.timeoutMs, DEFAULT_ALERT_TIMEOUT_MS); +}); diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 72d4762692..74e794092c 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -38,6 +38,9 @@ export const RUNNER_BUSY_RUNNER_CODE = 'RUNNER_BUSY'; */ export const MAIN_THREAD_TIMEOUT_RUNNER_CODE = 'MAIN_THREAD_TIMEOUT'; +/** The longest readiness preflight a ready runner session pays before a conservative command. */ +export const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000; + /** * The runner's own code for a command it refused because abandoned main-thread work has occupied it * past the wedge threshold (#1105). Like `RUNNER_BUSY`, the refused command never ran. diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index e734429286..3f97839277 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -663,7 +663,14 @@ export function isRetryableRunnerError(err: unknown): boolean { * `details.runnerErrorCode`), so family policy reads the typed detail rather than the message. */ export function isRunnerMainThreadOccupiedError(error: unknown): boolean { - if (isRunnerBusyError(error)) return true; + return isRunnerBusyError(error) || isRunnerMainThreadTimeoutError(error); +} + +/** + * True when the runner's execution watchdog abandoned this command's main-thread work + * (`MAIN_THREAD_TIMEOUT`). The command ran, so its effect is unknown, not refused. + */ +export function isRunnerMainThreadTimeoutError(error: unknown): boolean { return ( error instanceof AppError && error.details?.runnerErrorCode === MAIN_THREAD_TIMEOUT_RUNNER_CODE ); diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index 6608635e19..dfbdcbc95f 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -12,6 +12,7 @@ import { decodeRunnerResponseBody, isRunnerResponseOk, readRunnerResponseData, + RUNNER_READY_PREFLIGHT_TIMEOUT_MS, withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; @@ -50,7 +51,6 @@ type RunnerExchangeSession = RunnerConnectionSession & | 'launchDeadline' >; -const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000; const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000; const CANCELED_COMMAND_STATUS_TIMEOUT_MS = 1_000; const CANCELED_COMMAND_POLL_INTERVAL_MS = 100; diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 7854eadafd..aa4ba72fc0 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -199,7 +199,7 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - 'Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + 'Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only when the time the open request has left covers a whole press and the read that checks it. A recognized prompt still visible after a failed accept, or once that time is gone, also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: "metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index a8e1a83910..0b6bd81a50 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -112,6 +112,7 @@ iOS Simulator launch behavior: - When that prompt stays unanswered, `open` returns `launchConfirmation: "unanswered"` with `launchConfirmationReason`: `alert-unrecognized` for a prompt it did not recognize (such as a non-English Simulator), or `url-owner-unresolved` when no single installed app owns the URL's scheme or that lookup failed. The prompt stays on screen; `warnings` name the next step. For `url-owner-unresolved`, answer the prompt with `alert accept`. For `alert-unrecognized`, read the alert with `alert get` first: it can be the app's own alert, such as a permission prompt, rather than a translated launch confirmation. Answer with `alert accept` only when it is the launch confirmation. - When `open` could not read whether a prompt is up at all (the alert read, the accept, or the runner itself failed), `open` succeeds with `launchConfirmationProbeFailure` set to `alert-read`, `alert-accept`, or `runner` and no `launchConfirmation`, plus a warning to check with `alert get` before retrying. - `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. Answer it with `alert accept` or `alert dismiss` before continuing. +- When the runner reports its main-thread timeout for that tap and the prompt is still up, `open` taps `Open` again, but only when the time the `open` request has left covers a whole tap and the read that checks it. The first tap is always sent. - `details.reason: launch_confirmation_foreign_app` means another installed app owns the scheme (`details.foreignAppBundleId`). Close the prompt with `alert dismiss` and pass a URL your app handles. - A prompt that appears after `open` has already seen the app is not reported and not answered; use `alert accept`.