From b3578cb26efa13e3671bb310c32f3f77a0fe9f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 12:52:10 +0100 Subject: [PATCH 01/12] fix(ios): press Open again when the runner watchdog abandons the launch-URL accept On a freshly started runner, the launch confirmation's Open press can time out on the runner's main-thread watchdog (MAIN_THREAD_TIMEOUT) while the "Open in" prompt stays up, and open then failed with launch_confirmation_unanswered with budget to spare. The answer now presses Open again while the same confirmation is still up, within a 25-second accept budget that holds a whole press. Any other accept failure, or a prompt that is gone, ends the answer as before. Fixes #3861 Co-Authored-By: Claude Opus 5.5 --- .../src/launch-confirmation.test.ts | 70 +++++++++++++++++++ .../platform-apple/src/launch-confirmation.ts | 54 ++++++++++++-- packages/platform-apple/src/open-policy.ts | 3 +- .../src/runner/runner-error-classification.ts | 9 ++- src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 2 +- 6 files changed, 132 insertions(+), 8 deletions(-) diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 9289556651..d5ef4c9577 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() })); @@ -579,3 +584,68 @@ 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' }, + }); +} + +test('an accept the runner watchdog abandoned is pressed again while the confirmation is up', async () => { + const acceptAlert = vi + .fn<() => Promise>() + .mockRejectedValueOnce(runnerMainThreadTimeout()) + .mockResolvedValueOnce({}); + const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ outcome: 'accepted' }); + expect(acceptAlert).toHaveBeenCalledTimes(2); +}); + +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 }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ + outcome: 'unanswered', + reason: 'alert-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 }); + vi.mocked(device.readAlert).mockResolvedValue(undefined); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ + outcome: 'unreadable', + step: 'alert-accept', + }); + expect(acceptAlert).toHaveBeenCalledOnce(); +}); + +test('abandoned accepts stop once the answer budget cannot hold another press', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const acceptAlert = vi.fn(async () => { + vi.advanceTimersByTime(DEFAULT_ALERT_TIMEOUT_MS); + throw runnerMainThreadTimeout(); + }); + const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ + outcome: 'unanswered', + reason: 'alert-still-present', + }); + expect(acceptAlert).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index ce289ab5c8..eaf1296343 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -1,10 +1,13 @@ +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'; +import { Deadline } from '@agent-device/host-kit/retry'; 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 { isRunnerMainThreadTimeoutError } from './runner/runner-error-classification.ts'; export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app'; @@ -46,6 +49,15 @@ const LAUNCH_CONFIRMATION_TITLE = /^Open in [“"].+[”"]\?$/u; */ export const URL_OWNER_LOOKUP_TIMEOUT_MS = 10_000; +/** + * How long one answer may keep pressing `Open` after the runner's main-thread watchdog abandoned a + * press and the confirmation stayed up (#3861). A runner that has just started can stall its first + * press and serve the next. Another press starts only while the budget still holds the press's own + * runner bound, `DEFAULT_ALERT_TIMEOUT_MS`, so the presses end within the budget, well inside the + * open's request envelope. + */ +const LAUNCH_CONFIRMATION_ACCEPT_BUDGET_MS = 25_000; + /** A custom-scheme launch URL SpringBoard may hold for the session app it was checked for. */ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: string }>; @@ -101,9 +113,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, + Deadline.fromTimeoutMs(LAUNCH_CONFIRMATION_ACCEPT_BUDGET_MS), + ); +} + +/** + * Presses `Open` and verifies a failed press. Only a press the runner's watchdog abandoned, with the + * same confirmation still up, is pressed again, while the budget holds another whole press. Any + * other failure, and a confirmation that is gone, ends the answer at once. + */ +async function acceptConfirmation( + port: LaunchConfirmationPort, + original: Record, + budget: Deadline, +): 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; + if ( + !isRunnerMainThreadTimeoutError(failure.error) || + budget.remainingMs() < DEFAULT_ALERT_TIMEOUT_MS + ) { + reportUnanswered('alert-still-present', {}); + return attempt; + } + emitDiagnostic({ + level: 'debug', + phase: 'ios_launch_confirmation_reaccept', + data: { reason: 'runner-main-thread-timeout', remainingMs: budget.remainingMs() }, + }); + } } async function verifyFailedAcceptance( @@ -118,7 +165,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); diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index 575b46aacb..b56741ce76 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 within the answer's accept budget), 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/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/src/commands/management/app.ts b/src/commands/management/app.ts index 7854eadafd..55e25e2d3e 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 while a 25-second accept budget still holds a whole press. A recognized prompt still visible after a failed accept, or after that budget, 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..0da345cdd8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -111,7 +111,7 @@ iOS Simulator launch behavior: - When `open --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. - 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. +- `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. When the runner reports its main-thread timeout for the tap and the prompt is still up, `open` taps `Open` again for up to 25 seconds before it fails. Answer it with `alert accept` or `alert dismiss` before continuing. - `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`. From b526ce56aee0ae204bfaa23576599df29ff7312c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 13:15:28 +0100 Subject: [PATCH 02/12] docs(ios): state why a second launch-confirmation press cannot answer a later alert Co-Authored-By: Claude Opus 5.5 --- packages/platform-apple/src/launch-confirmation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index eaf1296343..137196c220 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -123,7 +123,9 @@ export async function answerLaunchConfirmation( /** * Presses `Open` and verifies a failed press. Only a press the runner's watchdog abandoned, with the * same confirmation still up, is pressed again, while the budget holds another whole press. Any - * other failure, and a confirmation that is gone, ends the answer at once. + * 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, From a771016ec323fb30cbd33686acb59af72108ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 13:34:59 +0100 Subject: [PATCH 03/12] fix(ios): bound the launch-confirmation re-press by the open request's deadline The re-press after a watchdog-abandoned accept used a fresh 25 s budget from the first press, which ignored the time open had already spent, and reserved only the press watchdog. The daemon now hands the open its request deadline: the enforced daemon deadline when the request has one, otherwise the transport admission time plus what the client envelope leaves the daemon. Another press starts only while that deadline covers the press and its verification read, each with a readiness preflight, and every runner alert command and the URL owner lookup are capped at the time left. A request without a deadline is not re-pressed. Refs #3861 Co-Authored-By: Claude Opus 5.5 --- .../command-registry/src/timeout-policy.ts | 13 ++ .../src/application-lifecycle-runtime.ts | 6 + packages/contracts/src/interactor-types.ts | 2 + .../src/core/daemon-command-registry.ts | 10 +- .../application-lifecycle-execution.test.ts | 73 ++++++++ .../application-lifecycle-execution.ts | 20 +++ .../host-kit/src/internal/request-cancel.ts | 12 +- packages/host-kit/src/request.ts | 1 + packages/platform-apple/src/alert.ts | 31 +++- .../src/launch-confirmation.test.ts | 169 +++++++++++++++--- .../platform-apple/src/launch-confirmation.ts | 55 +++--- packages/platform-apple/src/lifecycle.ts | 1 + packages/platform-apple/src/open-policy.ts | 4 +- .../src/runner/runner-contract.ts | 3 + .../src/runner/runner-exchange.ts | 2 +- src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 2 +- 17 files changed, 344 insertions(+), 62 deletions(-) create mode 100644 packages/daemon/src/sessions/application-lifecycle-execution.test.ts 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/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 8e6cee78fa..bb2364076b 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -541,6 +541,8 @@ export type Interactor = { */ export type AlertInteractorOptions = { timeoutMs?: number; + /** Absolute time no runner alert command may outlast; each leg caps its own budget at it. */ + deadlineAtMs?: number; /** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */ signal?: AbortSignal; appBundleId?: string; diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index fbbe12988c..50d8921e93 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -11,7 +11,10 @@ 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 { DaemonRequest } from './daemon-request.ts'; @@ -37,6 +40,11 @@ export function resolveDaemonRequestTimeoutMs(req: DaemonRequest): number | unde return resolveCommandDaemonTimeoutMs(resolveCommandTimeoutPolicy(req.command), req); } +/** How long the daemon may work on this request before its client gives up on the result. */ +export function resolveDaemonRequestBudgetMs(req: DaemonRequest): number | undefined { + return resolveCommandDaemonBudgetMs(resolveCommandTimeoutPolicy(req.command), req); +} + export function getDaemonCommandRoute(command: string): DaemonCommandRoute { return getDaemonCommandDescriptor(command)?.route ?? 'generic'; } 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..571caeb4f8 --- /dev/null +++ b/packages/daemon/src/sessions/application-lifecycle-execution.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { + clearRequestAbortRegistration, + registerRequestAbort, + withRequestDeadline, +} from '@agent-device/host-kit/request'; +import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import { applicationLifecycleExecutionFromRequest } from './application-lifecycle-execution.ts'; + +const ADMITTED_AT_MS = 1_000_000; + +afterEach(() => { + vi.useRealTimers(); +}); + +function openExecution( + requestId: string | undefined, + flags: CommandFlags = {}, +): ApplicationLifecycleExecution { + return applicationLifecycleExecutionFromRequest( + { + token: 'token', + session: 'default', + command: 'open', + positionals: ['com.example.app'], + flags, + meta: { requestId }, + }, + '/tmp/request.log', + ); +} + +/** Admits `requestId` at `ADMITTED_AT_MS`, then reads its execution 5 s later. */ +function executionAfterAdmission(requestId: string, flags?: CommandFlags) { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(ADMITTED_AT_MS); + const registration = registerRequestAbort(requestId); + try { + vi.setSystemTime(ADMITTED_AT_MS + 5_000); + return openExecution(requestId, flags); + } finally { + clearRequestAbortRegistration(registration); + } +} + +test('an open must settle within its client envelope less the daemon result margin', () => { + expect(executionAfterAdmission('open-default-envelope').requestDeadlineAtMs).toBe( + ADMITTED_AT_MS + 60_000, + ); +}); + +test('open --timeout moves the deadline with the envelope it widens', () => { + expect( + executionAfterAdmission('open-widened-envelope', { timeoutMs: 120_000 }).requestDeadlineAtMs, + ).toBe(ADMITTED_AT_MS + 120_000); +}); + +test('an enforced request deadline is the deadline', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(ADMITTED_AT_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(ADMITTED_AT_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..d075ada0cb 100644 --- a/packages/daemon/src/sessions/application-lifecycle-execution.ts +++ b/packages/daemon/src/sessions/application-lifecycle-execution.ts @@ -1,4 +1,9 @@ +import { + getRequestAdmittedAtMs, + getRequestExecutionDeadlineMs, +} from '@agent-device/host-kit/request'; import { resolvePlannedOperations } from '../core/execution-plan.ts'; +import { resolveDaemonRequestBudgetMs } from '../core/daemon-command-registry.ts'; import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; import { resolveRunnerLogicalLeaseContext } from '../core/lease-context.ts'; import type { DaemonRequest } from '../core/daemon-request.ts'; @@ -10,6 +15,7 @@ export function applicationLifecycleExecutionFromRequest( traceLogPath?: string, ): ApplicationLifecycleExecution { return { + requestDeadlineAtMs: requestDeadlineAtMs(req), requestId: req.meta?.requestId, logPath, traceLogPath, @@ -25,3 +31,17 @@ export function applicationLifecycleExecutionFromRequest( plannedOperations: resolvePlannedOperations(req.internal?.executionPlan), }; } + +/** + * An enforced daemon deadline is the request's deadline. Otherwise the client envelope started when + * the transport admitted the request, so the deadline is that moment plus the envelope's daemon + * budget. + */ +function requestDeadlineAtMs(req: DaemonRequest): number | undefined { + const requestId = req.meta?.requestId; + const enforcedAtMs = getRequestExecutionDeadlineMs(requestId); + if (enforcedAtMs !== undefined) return enforcedAtMs; + const admittedAtMs = getRequestAdmittedAtMs(requestId); + const budgetMs = resolveDaemonRequestBudgetMs(req); + return admittedAtMs === undefined || budgetMs === undefined ? undefined : admittedAtMs + budgetMs; +} diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 807c4811bb..1ba0a473f6 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -12,7 +12,11 @@ type RequestCancelState = { requestAbortControllers: Map; }; -type RequestAbortController = AbortController & { executionDeadlineMs?: number }; +type RequestAbortController = AbortController & { + executionDeadlineMs?: number; + /** When the transport admitted the request; its client envelope started at the same moment. */ + admittedAtMs?: number; +}; const REQUEST_CANCEL_SLOT = Symbol('requestCancel'); const requestCancelState = (): RequestCancelState => @@ -90,6 +94,7 @@ export function registerRequestAbort( } evictOldestEntries(requestAbortControllers()); const controller: RequestAbortController = new AbortController(); + controller.admittedAtMs = Date.now(); controller.executionDeadlineMs = parentRequestId ? requestAbortControllers().get(parentRequestId)?.executionDeadlineMs : undefined; @@ -154,6 +159,11 @@ export function getRequestExecutionDeadlineMs(requestId: string | undefined): nu return requestId ? requestAbortControllers().get(requestId)?.executionDeadlineMs : undefined; } +/** When the transport admitted the request, or `undefined` for a request it never admitted. */ +export function getRequestAdmittedAtMs(requestId: string | undefined): number | undefined { + return requestId ? requestAbortControllers().get(requestId)?.admittedAtMs : 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..4f1e304fbe 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -2,6 +2,7 @@ export { cancelAllRequests, clearRequestAbortRegistration, clearRequestCanceled, + getRequestAdmittedAtMs, getRequestSignal, getRequestExecutionDeadlineMs, isRequestCanceled, diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index baa33228ce..b20ffdc2fa 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -75,7 +75,12 @@ export async function readAppleAlert( options: AlertInteractorOptions | undefined, env: Readonly, ): Promise> { - return await runAppleAlert(device, runnerOptions, options, env)('get', DEFAULT_ALERT_TIMEOUT_MS); + return await runAppleAlert( + device, + runnerOptions, + options, + env, + )('get', cappedAtDeadline(DEFAULT_ALERT_TIMEOUT_MS, options)); } /** @@ -89,7 +94,7 @@ export async function awaitAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); - const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; + const timeout = cappedAtDeadline(options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS, options); const start = Date.now(); let firstAttempt = true; let lastAbsence: AppError | undefined; @@ -123,15 +128,17 @@ export async function actOnAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); - const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; + const runnerTimeoutMs = cappedAtDeadline( + isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS, + options, + ); + const retryWindowMs = cappedAtDeadline(ALERT_ACTION_RETRY_MS, options); const start = Date.now(); let lastError: unknown; let firstAttempt = true; - while (Date.now() - start < ALERT_ACTION_RETRY_MS) { + while (Date.now() - start < retryWindowMs) { try { - const budgetMs = firstAttempt - ? runnerTimeoutMs - : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); + const budgetMs = firstAttempt ? runnerTimeoutMs : remainingBudgetMs(start, retryWindowMs); firstAttempt = false; return await runAlert(action, budgetMs); } catch (error) { @@ -143,6 +150,16 @@ export async function actOnAppleAlert( throw withAlertFallbackHint(lastError); } +function cappedAtDeadline(budgetMs: number, options: AlertInteractorOptions | undefined): number { + return capAtDeadline(budgetMs, options?.deadlineAtMs); +} + +/** A step's own budget, capped at the time the caller's deadline still leaves (at least 1 ms). */ +export function capAtDeadline(budgetMs: number, deadlineAtMs: number | undefined): number { + if (deadlineAtMs === undefined) return budgetMs; + return Math.max(1, Math.min(budgetMs, deadlineAtMs - Date.now())); +} + function remainingBudgetMs(start: number, timeoutMs: number): number { return Math.max(1, timeoutMs - (Date.now() - start)); } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index d5ef4c9577..7141062e81 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; -import { awaitAppleAlert, readAppleAlert } from './alert.ts'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner-client.ts'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -60,6 +60,7 @@ function port( legs: Readonly<{ resolveUrlOwner?: () => Promise; acceptAlert?: () => Promise; + deadlineAtMs?: number; }> = {}, ) { const acceptAlert = vi.fn(legs.acceptAlert ?? (async () => ({}))); @@ -70,6 +71,7 @@ function port( readAlert: vi.fn(readAlert), acceptAlert, resolveUrlOwner, + deadlineAtMs: legs.deadlineAtMs, }; return { port: value, acceptAlert, resolveUrlOwner }; } @@ -291,6 +293,7 @@ test.each([ { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), new AbortController().signal, + undefined, ).then((value) => (answered = value)), ); @@ -331,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)), ); @@ -358,6 +362,7 @@ test.each([ { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), open.signal, + undefined, ), ); @@ -383,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); @@ -408,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); @@ -442,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) => { @@ -475,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) => { @@ -505,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(); @@ -537,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(); @@ -550,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' }); }); @@ -593,27 +605,66 @@ function runnerMainThreadTimeout(): AppError { }); } -test('an accept the runner watchdog abandoned is pressed again while the confirmation is up', async () => { +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 }); + 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('the watchdog message without the runner code is not pressed again', async () => { +test('an abandoned accept is not pressed again when the request has no deadline', async () => { const acceptAlert = vi.fn(async () => { - throw new AppError('COMMAND_FAILED', 'main thread execution timed out'); + throw runnerMainThreadTimeout(); }); const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); - await expect(answerLaunchConfirmation(device)).resolves.toEqual({ - outcome: 'unanswered', - reason: 'alert-still-present', + 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(); }); @@ -621,7 +672,10 @@ test('an abandoned accept that removed the confirmation is not pressed again', a const acceptAlert = vi.fn(async () => { throw runnerMainThreadTimeout(); }); - const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 60_000, + }); vi.mocked(device.readAlert).mockResolvedValue(undefined); await expect(answerLaunchConfirmation(device)).resolves.toEqual({ @@ -631,21 +685,92 @@ test('an abandoned accept that removed the confirmation is not pressed again', a expect(acceptAlert).toHaveBeenCalledOnce(); }); -test('abandoned accepts stop once the answer budget cannot hold another press', async () => { - vi.useFakeTimers({ toFake: ['Date'] }); - try { - const acceptAlert = vi.fn(async () => { - vi.advanceTimersByTime(DEFAULT_ALERT_TIMEOUT_MS); - throw runnerMainThreadTimeout(); +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, }); - const { port: device } = port(async () => CONFIRMATION, { acceptAlert }); - await expect(answerLaunchConfirmation(device)).resolves.toEqual({ - outcome: 'unanswered', - reason: 'alert-still-present', + 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); - } finally { - vi.useRealTimers(); - } + }); +}); + +test('every runner alert command of the answer is capped at the request time left', async () => { + await onFakeClock(async () => { + const sent: Array> = []; + vi.mocked(runAppleRunnerCommand).mockImplementation(async (_device, command) => { + const { action, timeoutMs } = command as { action?: string; timeoutMs?: number }; + sent.push({ action, timeoutMs }); + if (action !== 'accept') return CONFIRMATION; + vi.advanceTimersByTime(timeoutMs ?? 0); + throw runnerMainThreadTimeout(); + }); + vi.mocked(resolveIosSimulatorDeepLinkBundleId).mockResolvedValueOnce('com.example.app'); + const interactor = { + awaitAlert: async (options) => await awaitAppleAlert(simulator, {}, options, {}), + readAlert: async (options) => await readAppleAlert(simulator, {}, options, {}), + acceptAlert: async (options) => await actOnAppleAlert(simulator, {}, 'accept', options, {}), + } satisfies Pick; + + await expect( + answerSimulatorLaunchConfirmation( + simulator, + { url: 'example://automation', appBundleId: 'com.example.app' }, + Promise.resolve(interactor as Interactor), + new AbortController().signal, + Date.now() + 6_000, + ), + ).resolves.toEqual(STILL_PRESENT); + expect(sent).toEqual([ + { action: 'get', timeoutMs: 3_000 }, + { action: 'accept', timeoutMs: 6_000 }, + { action: 'get', timeoutMs: 1 }, + ]); + }); }); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index 137196c220..0e92698ff6 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -2,11 +2,11 @@ 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'; -import { Deadline } from '@agent-device/host-kit/retry'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { alertIfPresent } from './alert.ts'; +import { alertIfPresent, capAtDeadline } 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'; @@ -50,13 +50,10 @@ const LAUNCH_CONFIRMATION_TITLE = /^Open in [“"].+[”"]\?$/u; export const URL_OWNER_LOOKUP_TIMEOUT_MS = 10_000; /** - * How long one answer may keep pressing `Open` after the runner's main-thread watchdog abandoned a - * press and the confirmation stayed up (#3861). A runner that has just started can stall its first - * press and serve the next. Another press starts only while the budget still holds the press's own - * runner bound, `DEFAULT_ALERT_TIMEOUT_MS`, so the presses end within the budget, well inside the - * open's request envelope. + * 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 LAUNCH_CONFIRMATION_ACCEPT_BUDGET_MS = 25_000; +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 }>; @@ -64,6 +61,11 @@ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: stri /** 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. Every step is capped at it, and only a request + * with one has time to press `Open` again. + */ + 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. */ @@ -113,24 +115,20 @@ 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 acceptConfirmation( - port, - alert, - Deadline.fromTimeoutMs(LAUNCH_CONFIRMATION_ACCEPT_BUDGET_MS), - ); + 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, while the budget holds another whole press. 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. + * 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, - budget: Deadline, ): Promise { for (;;) { const failure = await port.acceptAlert().then( @@ -140,21 +138,23 @@ async function acceptConfirmation( if (!failure) return accepted(); const attempt = await verifyFailedAcceptance(port, original, failure.error); if (attempt.outcome !== 'unanswered') return attempt; - if ( - !isRunnerMainThreadTimeoutError(failure.error) || - budget.remainingMs() < DEFAULT_ALERT_TIMEOUT_MS - ) { + 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: budget.remainingMs() }, + 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( port: LaunchConfirmationPort, original: Record, @@ -229,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), ); } @@ -243,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); @@ -250,9 +252,10 @@ export function createLaunchConfirmationPort( if (!awaitAlert || !readAlert || !acceptAlert) { throw invalidRuntimeContract('Apple interactor has no alert wait, read or accept leg'); } - const target = { appBundleId, surface: 'app' } as const; + const target = { appBundleId, surface: 'app', deadlineAtMs } as const; return Object.freeze({ appBundleId, + deadlineAtMs, awaitAlert: async () => await alertIfPresent( awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), @@ -261,7 +264,7 @@ export function createLaunchConfirmationPort( acceptAlert: async () => await acceptAlert(target), resolveUrlOwner: async () => await resolveIosSimulatorDeepLinkBundleId(device, url, { - timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, + timeoutMs: capAtDeadline(URL_OWNER_LOOKUP_TIMEOUT_MS, deadlineAtMs), signal, }), }); 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 b56741ce76..0c939d96da 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -198,8 +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 (an accept the runner watchdog abandoned is pressed - * again only within the answer's accept budget), and the observation at the discovery deadline and + * read and accept at the runner's alert timeouts, capped at the request's deadline (an accept the + * runner watchdog abandoned is pressed again only while that deadline holds another press), 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/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-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 55e25e2d3e..9bd0a91a76 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. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again while a 25-second accept budget still holds a whole press. A recognized prompt still visible after a failed accept, or after that budget, 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 while the open request still has time for 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 0da345cdd8..a162989717 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -111,7 +111,7 @@ iOS Simulator launch behavior: - When `open --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. - 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. When the runner reports its main-thread timeout for the tap and the prompt is still up, `open` taps `Open` again for up to 25 seconds before it fails. Answer it with `alert accept` or `alert dismiss` before continuing. +- `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. When the runner reports its main-thread timeout for the tap and the prompt is still up, `open` taps `Open` again. It does so only while the `open` request still has time for a whole tap and the read that checks it; otherwise it fails. Answer it with `alert accept` or `alert dismiss` before continuing. - `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`. From a75a528aa01f327c6582043a69cb36f95a69c8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 13:50:09 +0100 Subject: [PATCH 04/12] fix(ios): take the open deadline from the admitting transport and keep alert failures typed A request's result deadline is now recorded by the transport that admitted it, from the top-level command's client envelope, and an attempt registered under a parent inherits the parent's. A replay test attempt under an unbounded `test` request therefore has no deadline, instead of one counted from its own registration with the nested open's budget. An accept whose deadline already passed floored its retry window to 1 ms and could skip the loop and throw undefined; the first attempt now always runs. Refs #3861 Co-Authored-By: Claude Opus 5.5 --- .../src/core/daemon-command-registry.ts | 15 ++- .../http-server-rpc-lifetime.test.ts | 31 +++++- .../daemon/src/server/http/http-server.ts | 5 +- .../daemon/src/server/http/transport.test.ts | 94 ++++++++++++++++++- packages/daemon/src/server/http/transport.ts | 5 +- .../application-lifecycle-execution.test.ts | 44 +-------- .../application-lifecycle-execution.ts | 14 +-- .../host-kit/src/internal/request-cancel.ts | 24 +++-- packages/host-kit/src/request.ts | 2 +- .../src/__tests__/alert.test.ts | 28 ++++++ packages/platform-apple/src/alert.ts | 6 +- .../src/launch-confirmation.test.ts | 5 +- 12 files changed, 202 insertions(+), 71 deletions(-) diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index 50d8921e93..9a91a01f05 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -16,6 +16,7 @@ import { 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 }; @@ -40,9 +41,17 @@ export function resolveDaemonRequestTimeoutMs(req: DaemonRequest): number | unde return resolveCommandDaemonTimeoutMs(resolveCommandTimeoutPolicy(req.command), req); } -/** How long the daemon may work on this request before its client gives up on the result. */ -export function resolveDaemonRequestBudgetMs(req: DaemonRequest): number | undefined { - return resolveCommandDaemonBudgetMs(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 { 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..44cccadf90 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 must settle within 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 index 571caeb4f8..ed08252d26 100644 --- a/packages/daemon/src/sessions/application-lifecycle-execution.test.ts +++ b/packages/daemon/src/sessions/application-lifecycle-execution.test.ts @@ -1,70 +1,36 @@ import { afterEach, expect, test, vi } from 'vitest'; -import { - clearRequestAbortRegistration, - registerRequestAbort, - withRequestDeadline, -} from '@agent-device/host-kit/request'; +import { withRequestDeadline } from '@agent-device/host-kit/request'; import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { CommandFlags } from '@agent-device/contracts/command'; import { applicationLifecycleExecutionFromRequest } from './application-lifecycle-execution.ts'; -const ADMITTED_AT_MS = 1_000_000; +const NOW_MS = 1_000_000; afterEach(() => { vi.useRealTimers(); }); -function openExecution( - requestId: string | undefined, - flags: CommandFlags = {}, -): ApplicationLifecycleExecution { +function openExecution(requestId: string | undefined): ApplicationLifecycleExecution { return applicationLifecycleExecutionFromRequest( { token: 'token', session: 'default', command: 'open', positionals: ['com.example.app'], - flags, meta: { requestId }, }, '/tmp/request.log', ); } -/** Admits `requestId` at `ADMITTED_AT_MS`, then reads its execution 5 s later. */ -function executionAfterAdmission(requestId: string, flags?: CommandFlags) { - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(ADMITTED_AT_MS); - const registration = registerRequestAbort(requestId); - try { - vi.setSystemTime(ADMITTED_AT_MS + 5_000); - return openExecution(requestId, flags); - } finally { - clearRequestAbortRegistration(registration); - } -} - -test('an open must settle within its client envelope less the daemon result margin', () => { - expect(executionAfterAdmission('open-default-envelope').requestDeadlineAtMs).toBe( - ADMITTED_AT_MS + 60_000, - ); -}); - -test('open --timeout moves the deadline with the envelope it widens', () => { - expect( - executionAfterAdmission('open-widened-envelope', { timeoutMs: 120_000 }).requestDeadlineAtMs, - ).toBe(ADMITTED_AT_MS + 120_000); -}); - test('an enforced request deadline is the deadline', async () => { vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(ADMITTED_AT_MS); + 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(ADMITTED_AT_MS + 20_000); + expect(execution?.requestDeadlineAtMs).toBe(NOW_MS + 20_000); }); test('a request no transport admitted has no deadline', () => { diff --git a/packages/daemon/src/sessions/application-lifecycle-execution.ts b/packages/daemon/src/sessions/application-lifecycle-execution.ts index d075ada0cb..e8e4eeb9d8 100644 --- a/packages/daemon/src/sessions/application-lifecycle-execution.ts +++ b/packages/daemon/src/sessions/application-lifecycle-execution.ts @@ -1,9 +1,8 @@ import { - getRequestAdmittedAtMs, getRequestExecutionDeadlineMs, + getRequestResultDeadlineMs, } from '@agent-device/host-kit/request'; import { resolvePlannedOperations } from '../core/execution-plan.ts'; -import { resolveDaemonRequestBudgetMs } from '../core/daemon-command-registry.ts'; import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; import { resolveRunnerLogicalLeaseContext } from '../core/lease-context.ts'; import type { DaemonRequest } from '../core/daemon-request.ts'; @@ -33,15 +32,10 @@ export function applicationLifecycleExecutionFromRequest( } /** - * An enforced daemon deadline is the request's deadline. Otherwise the client envelope started when - * the transport admitted the request, so the deadline is that moment plus the envelope's daemon - * budget. + * An enforced daemon deadline is the request's deadline. Otherwise it is when the client stops + * waiting for the result, as the transport that admitted the request (or its parent) recorded it. */ function requestDeadlineAtMs(req: DaemonRequest): number | undefined { const requestId = req.meta?.requestId; - const enforcedAtMs = getRequestExecutionDeadlineMs(requestId); - if (enforcedAtMs !== undefined) return enforcedAtMs; - const admittedAtMs = getRequestAdmittedAtMs(requestId); - const budgetMs = resolveDaemonRequestBudgetMs(req); - return admittedAtMs === undefined || budgetMs === undefined ? undefined : admittedAtMs + budgetMs; + 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 1ba0a473f6..7945c4451d 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -14,8 +14,12 @@ type RequestCancelState = { type RequestAbortController = AbortController & { executionDeadlineMs?: number; - /** When the transport admitted the request; its client envelope started at the same moment. */ - admittedAtMs?: number; + /** + * When the client stops waiting for the result, as the transport that admitted the request + * computed it. An attempt registered under a parent inherits the parent's, because it runs inside + * the parent's client envelope. + */ + resultDeadlineMs?: number; }; const REQUEST_CANCEL_SLOT = Symbol('requestCancel'); @@ -84,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)) { @@ -94,10 +99,11 @@ export function registerRequestAbort( } evictOldestEntries(requestAbortControllers()); const controller: RequestAbortController = new AbortController(); - controller.admittedAtMs = Date.now(); - 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()); @@ -159,9 +165,9 @@ export function getRequestExecutionDeadlineMs(requestId: string | undefined): nu return requestId ? requestAbortControllers().get(requestId)?.executionDeadlineMs : undefined; } -/** When the transport admitted the request, or `undefined` for a request it never admitted. */ -export function getRequestAdmittedAtMs(requestId: string | undefined): number | undefined { - return requestId ? requestAbortControllers().get(requestId)?.admittedAtMs : undefined; +/** When the client stops waiting for the request's result, if the admitting transport knew. */ +export function getRequestResultDeadlineMs(requestId: string | undefined): number | undefined { + return requestId ? requestAbortControllers().get(requestId)?.resultDeadlineMs : undefined; } export function throwIfRequestCanceled(requestId: string | undefined): void { diff --git a/packages/host-kit/src/request.ts b/packages/host-kit/src/request.ts index 4f1e304fbe..a0bc4cebc9 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -2,9 +2,9 @@ export { cancelAllRequests, clearRequestAbortRegistration, clearRequestCanceled, - getRequestAdmittedAtMs, getRequestSignal, getRequestExecutionDeadlineMs, + getRequestResultDeadlineMs, isRequestCanceled, markRequestCanceled, registerRequestAbort, diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index 5bdf5180b2..e35c28b1ad 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -281,3 +281,31 @@ test('a macOS app session forwards the bundle its surface is scoped to', async ( { bundleId: 'com.example.app', surface: 'app', env: process.env }, ]); }); + +test('an accept whose deadline already passed still asks once and reports the typed failure', async () => { + // Every clock read lands 5 ms later, so the retry window a past deadline floors to 1 ms is + // already spent by the time the loop could first check it. + let nowMs = 1_000_000; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => (nowMs += 5)); + mockRunner.mockRejectedValue(runnerAbsence()); + + const failure = await actOnAppleAlert( + IOS_SIMULATOR, + runnerOptions, + 'accept', + { deadlineAtMs: 1_000_000 - 60_000 }, + process.env, + ) + .catch((error: unknown) => error) + .finally(() => clock.mockRestore()); + + assert.ok(failure instanceof AppError, 'an expired deadline must reject with an AppError'); + assert.equal(failure.details?.runnerErrorCode, ALERT_NOT_FOUND_RUNNER_CODE); + assert.equal(mockRunner.mock.calls.length, 1); + assert.deepEqual(mockRunner.mock.calls[0]?.[1], { + command: 'alert', + action: 'accept', + appBundleId: undefined, + timeoutMs: 1, + }); +}); diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index b20ffdc2fa..292e94d389 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -136,7 +136,9 @@ export async function actOnAppleAlert( const start = Date.now(); let lastError: unknown; let firstAttempt = true; - while (Date.now() - start < retryWindowMs) { + // The first attempt always runs, so a window a spent deadline floored to 1 ms still ends with the + // backend's own answer rather than with no error at all. + do { try { const budgetMs = firstAttempt ? runnerTimeoutMs : remainingBudgetMs(start, retryWindowMs); firstAttempt = false; @@ -146,7 +148,7 @@ export async function actOnAppleAlert( if (!isAlertNotFoundError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); - } + } while (Date.now() - start < retryWindowMs); throw withAlertFallbackHint(lastError); } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 7141062e81..716e8236e7 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -742,9 +742,10 @@ test('abandoned accepts stop once the request cannot hold another press', async }); test('every runner alert command of the answer is capped at the request time left', async () => { + const runner = vi.mocked(runAppleRunnerCommand); await onFakeClock(async () => { const sent: Array> = []; - vi.mocked(runAppleRunnerCommand).mockImplementation(async (_device, command) => { + runner.mockImplementation(async (_device, command) => { const { action, timeoutMs } = command as { action?: string; timeoutMs?: number }; sent.push({ action, timeoutMs }); if (action !== 'accept') return CONFIRMATION; @@ -772,5 +773,5 @@ test('every runner alert command of the answer is capped at the request time lef { action: 'accept', timeoutMs: 6_000 }, { action: 'get', timeoutMs: 1 }, ]); - }); + }).finally(() => runner.mockReset()); }); From 80a53b5f4651e8694e49e6a1c66cdbc3250655db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 13:59:29 +0100 Subject: [PATCH 05/12] test(ios): keep the expired-deadline accept test off real time and name the deadline tests exactly Co-Authored-By: Claude Opus 5.5 --- .../src/server/__tests__/http-server-rpc-lifetime.test.ts | 2 +- .../src/sessions/application-lifecycle-execution.ts | 4 ++-- packages/host-kit/src/internal/request-cancel.ts | 8 ++++---- packages/platform-apple/src/__tests__/alert.test.ts | 5 ++++- 4 files changed, 11 insertions(+), 8 deletions(-) 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 44cccadf90..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 @@ -35,7 +35,7 @@ test('an rpc request registers its tracking id while in flight and releases it o } }); -test('an admitted open must settle within its client envelope less the daemon result margin', async (t) => { +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) => { diff --git a/packages/daemon/src/sessions/application-lifecycle-execution.ts b/packages/daemon/src/sessions/application-lifecycle-execution.ts index e8e4eeb9d8..2e7d18699a 100644 --- a/packages/daemon/src/sessions/application-lifecycle-execution.ts +++ b/packages/daemon/src/sessions/application-lifecycle-execution.ts @@ -32,8 +32,8 @@ export function applicationLifecycleExecutionFromRequest( } /** - * An enforced daemon deadline is the request's deadline. Otherwise it is when the client stops - * waiting for the result, as the transport that admitted the request (or its parent) recorded it. + * 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; diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 7945c4451d..2abf1ecebc 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -15,9 +15,9 @@ type RequestCancelState = { type RequestAbortController = AbortController & { executionDeadlineMs?: number; /** - * When the client stops waiting for the result, as the transport that admitted the request - * computed it. An attempt registered under a parent inherits the parent's, because it runs inside - * the parent's client envelope. + * 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; }; @@ -165,7 +165,7 @@ export function getRequestExecutionDeadlineMs(requestId: string | undefined): nu return requestId ? requestAbortControllers().get(requestId)?.executionDeadlineMs : undefined; } -/** When the client stops waiting for the request's result, if the admitting transport knew. */ +/** 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; } diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index e35c28b1ad..a385d6f2c8 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -283,13 +283,14 @@ test('a macOS app session forwards the bundle its surface is scoped to', async ( }); test('an accept whose deadline already passed still asks once and reports the typed failure', async () => { + vi.useFakeTimers(); // Every clock read lands 5 ms later, so the retry window a past deadline floors to 1 ms is // already spent by the time the loop could first check it. let nowMs = 1_000_000; const clock = vi.spyOn(Date, 'now').mockImplementation(() => (nowMs += 5)); mockRunner.mockRejectedValue(runnerAbsence()); - const failure = await actOnAppleAlert( + const outcome = actOnAppleAlert( IOS_SIMULATOR, runnerOptions, 'accept', @@ -298,6 +299,8 @@ test('an accept whose deadline already passed still asks once and reports the ty ) .catch((error: unknown) => error) .finally(() => clock.mockRestore()); + await vi.advanceTimersByTimeAsync(3_500); + const failure = await outcome; assert.ok(failure instanceof AppError, 'an expired deadline must reject with an AppError'); assert.equal(failure.details?.runnerErrorCode, ALERT_NOT_FOUND_RUNNER_CODE); From 774329102cf696335532ee17a71f1a7db684d46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 14:11:11 +0100 Subject: [PATCH 06/12] fix(ios): refuse launch-confirmation runner steps the deadline cannot hold The deadline only shrank the alert payload's timeout before the readiness preflight, and a spent deadline still dispatched with a 1 ms floor. An alert runner command now carries a host-side deadline that the exchange turns into the runner timeout right before the payload, after the preflight; a press left less than its whole watchdog, or any command left no time, is refused unsent with the typed `runner_command_deadline_spent`. Once the deadline has passed, the launch confirmation sends nothing, and a press refused unsent leaves the confirmation unanswered. Runner startup and transport overhead stay outside this bound. Refs #3861 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/alert.test.ts | 31 ------ packages/platform-apple/src/alert.ts | 53 +++++----- .../src/launch-confirmation.test.ts | 62 ++++++++--- .../platform-apple/src/launch-confirmation.ts | 50 ++++++--- .../runner-exchange-deadline.test.ts | 100 ++++++++++++++++++ .../src/runner/runner-contract.ts | 41 +++++++ .../src/runner/runner-exchange.ts | 13 +-- src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 2 +- 9 files changed, 256 insertions(+), 98 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index a385d6f2c8..5bdf5180b2 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -281,34 +281,3 @@ test('a macOS app session forwards the bundle its surface is scoped to', async ( { bundleId: 'com.example.app', surface: 'app', env: process.env }, ]); }); - -test('an accept whose deadline already passed still asks once and reports the typed failure', async () => { - vi.useFakeTimers(); - // Every clock read lands 5 ms later, so the retry window a past deadline floors to 1 ms is - // already spent by the time the loop could first check it. - let nowMs = 1_000_000; - const clock = vi.spyOn(Date, 'now').mockImplementation(() => (nowMs += 5)); - mockRunner.mockRejectedValue(runnerAbsence()); - - const outcome = actOnAppleAlert( - IOS_SIMULATOR, - runnerOptions, - 'accept', - { deadlineAtMs: 1_000_000 - 60_000 }, - process.env, - ) - .catch((error: unknown) => error) - .finally(() => clock.mockRestore()); - await vi.advanceTimersByTimeAsync(3_500); - const failure = await outcome; - - assert.ok(failure instanceof AppError, 'an expired deadline must reject with an AppError'); - assert.equal(failure.details?.runnerErrorCode, ALERT_NOT_FOUND_RUNNER_CODE); - assert.equal(mockRunner.mock.calls.length, 1); - assert.deepEqual(mockRunner.mock.calls[0]?.[1], { - command: 'alert', - action: 'accept', - appBundleId: undefined, - timeoutMs: 1, - }); -}); diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index 292e94d389..9c09f20d8f 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -51,10 +51,26 @@ function runAppleAlert( return async (action) => (await runMacOsAlertAction(action, { ...target, env })) as Record; } + // A press needs its whole watchdog; a read is worth sending while any time is left. + const deadline = (action: NativeAlertAction, timeoutMs: number) => + options?.deadlineAtMs === undefined + ? {} + : { + deadline: { + atMs: options.deadlineAtMs, + minTimeoutMs: action === 'get' ? 1 : timeoutMs, + }, + }; return async (action, timeoutMs) => (await runAppleRunnerCommand( device, - { command: 'alert', action, appBundleId: options?.appBundleId, timeoutMs }, + { + command: 'alert', + action, + appBundleId: options?.appBundleId, + timeoutMs, + ...deadline(action, timeoutMs), + }, runnerOptions, )) as Record; } @@ -75,12 +91,7 @@ export async function readAppleAlert( options: AlertInteractorOptions | undefined, env: Readonly, ): Promise> { - return await runAppleAlert( - device, - runnerOptions, - options, - env, - )('get', cappedAtDeadline(DEFAULT_ALERT_TIMEOUT_MS, options)); + return await runAppleAlert(device, runnerOptions, options, env)('get', DEFAULT_ALERT_TIMEOUT_MS); } /** @@ -94,7 +105,7 @@ export async function awaitAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); - const timeout = cappedAtDeadline(options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS, options); + const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; const start = Date.now(); let firstAttempt = true; let lastAbsence: AppError | undefined; @@ -128,19 +139,15 @@ export async function actOnAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); - const runnerTimeoutMs = cappedAtDeadline( - isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS, - options, - ); - const retryWindowMs = cappedAtDeadline(ALERT_ACTION_RETRY_MS, options); + const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; const start = Date.now(); let lastError: unknown; let firstAttempt = true; - // The first attempt always runs, so a window a spent deadline floored to 1 ms still ends with the - // backend's own answer rather than with no error at all. - do { + while (Date.now() - start < ALERT_ACTION_RETRY_MS) { try { - const budgetMs = firstAttempt ? runnerTimeoutMs : remainingBudgetMs(start, retryWindowMs); + const budgetMs = firstAttempt + ? runnerTimeoutMs + : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); firstAttempt = false; return await runAlert(action, budgetMs); } catch (error) { @@ -148,20 +155,10 @@ export async function actOnAppleAlert( if (!isAlertNotFoundError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); - } while (Date.now() - start < retryWindowMs); + } throw withAlertFallbackHint(lastError); } -function cappedAtDeadline(budgetMs: number, options: AlertInteractorOptions | undefined): number { - return capAtDeadline(budgetMs, options?.deadlineAtMs); -} - -/** A step's own budget, capped at the time the caller's deadline still leaves (at least 1 ms). */ -export function capAtDeadline(budgetMs: number, deadlineAtMs: number | undefined): number { - if (deadlineAtMs === undefined) return budgetMs; - return Math.max(1, Math.min(budgetMs, deadlineAtMs - Date.now())); -} - function remainingBudgetMs(start: number, timeoutMs: number): number { return Math.max(1, timeoutMs - (Date.now() - start)); } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 716e8236e7..5a0005b663 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -741,37 +741,65 @@ test('abandoned accepts stop once the request cannot hold another press', async }); }); -test('every runner alert command of the answer is capped at the request time left', async () => { +/** The Apple alert legs over the mocked runner client, as the session interactor binds them. */ +function runnerAlertLegs() { + return { + awaitAlert: async (options) => await awaitAppleAlert(simulator, {}, options, {}), + readAlert: async (options) => await readAppleAlert(simulator, {}, options, {}), + acceptAlert: async (options) => await actOnAppleAlert(simulator, {}, 'accept', options, {}), + } satisfies Pick; +} + +test('a deadline spent by the press sends no verification read and keeps its unknown effect', async () => { const runner = vi.mocked(runAppleRunnerCommand); await onFakeClock(async () => { - const sent: Array> = []; + const deadlineAtMs = Date.now() + 12_000; + const sent: unknown[] = []; runner.mockImplementation(async (_device, command) => { - const { action, timeoutMs } = command as { action?: string; timeoutMs?: number }; - sent.push({ action, timeoutMs }); - if (action !== 'accept') return CONFIRMATION; - vi.advanceTimersByTime(timeoutMs ?? 0); + sent.push(command); + if ((command as { action?: string }).action !== 'accept') return CONFIRMATION; + vi.advanceTimersByTime(12_000); throw runnerMainThreadTimeout(); }); vi.mocked(resolveIosSimulatorDeepLinkBundleId).mockResolvedValueOnce('com.example.app'); - const interactor = { - awaitAlert: async (options) => await awaitAppleAlert(simulator, {}, options, {}), - readAlert: async (options) => await readAppleAlert(simulator, {}, options, {}), - acceptAlert: async (options) => await actOnAppleAlert(simulator, {}, 'accept', options, {}), - } satisfies Pick; await expect( answerSimulatorLaunchConfirmation( simulator, { url: 'example://automation', appBundleId: 'com.example.app' }, - Promise.resolve(interactor as Interactor), + Promise.resolve(runnerAlertLegs() as Interactor), new AbortController().signal, - Date.now() + 6_000, + deadlineAtMs, ), - ).resolves.toEqual(STILL_PRESENT); + ).resolves.toEqual({ outcome: 'unreadable', step: 'alert-accept' }); expect(sent).toEqual([ - { action: 'get', timeoutMs: 3_000 }, - { action: 'accept', timeoutMs: 6_000 }, - { action: 'get', timeoutMs: 1 }, + expect.objectContaining({ + action: 'get', + timeoutMs: 3_000, + deadline: { atMs: deadlineAtMs, minTimeoutMs: 1 }, + }), + expect.objectContaining({ + action: 'accept', + timeoutMs: DEFAULT_ALERT_TIMEOUT_MS, + deadline: { atMs: deadlineAtMs, minTimeoutMs: DEFAULT_ALERT_TIMEOUT_MS }, + }), ]); }).finally(() => runner.mockReset()); }); + +test('a press the runner client refused unsent leaves the confirmation unanswered unread', async () => { + const acceptAlert = vi.fn(async () => { + throw new AppError('COMMAND_FAILED', 'The deadline left no time to send this runner command.', { + reason: 'runner_command_deadline_spent', + dispatched: 'no', + }); + }); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert, + deadlineAtMs: Date.now() + 60_000, + }); + + await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); + expect(acceptAlert).toHaveBeenCalledOnce(); + expect(device.readAlert).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index 0e92698ff6..e3dbb7f21d 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -4,9 +4,13 @@ import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { alertIfPresent, capAtDeadline } from './alert.ts'; +import { alertIfPresent } from './alert.ts'; import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts'; -import { RUNNER_READY_PREFLIGHT_TIMEOUT_MS } from './runner/runner-contract.ts'; +import { + isRunnerCommandDeadlineSpentError, + RUNNER_READY_PREFLIGHT_TIMEOUT_MS, + runnerCommandDeadlineSpentError, +} from './runner/runner-contract.ts'; import { isRunnerMainThreadTimeoutError } from './runner/runner-error-classification.ts'; export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app'; @@ -62,8 +66,8 @@ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: stri export type LaunchConfirmationPort = Readonly<{ appBundleId: string; /** - * When the enclosing request must have its result. Every step is capped at it, and only a request - * with one has time to press `Open` again. + * When the enclosing request must have its result. Once it passes, no step is sent; runner + * timeouts are capped at it; and only a request with one has time to press `Open` again. */ deadlineAtMs: number | undefined; /** Initial presentation wait, or `undefined` when no alert appears within its window. */ @@ -136,6 +140,11 @@ async function acceptConfirmation( (error: unknown) => ({ error }), ); if (!failure) return accepted(); + if (isRunnerCommandDeadlineSpentError(failure.error)) { + // Refused unsent: nothing was pressed, so the confirmation just seen is still up. + reportUnanswered('alert-still-present', { pressSent: false }); + return { outcome: 'unanswered', reason: 'alert-still-present' }; + } const attempt = await verifyFailedAcceptance(port, original, failure.error); if (attempt.outcome !== 'unanswered') return attempt; const remainingMs = timeLeft(port.deadlineAtMs); @@ -253,19 +262,36 @@ export function createLaunchConfirmationPort( throw invalidRuntimeContract('Apple interactor has no alert wait, read or accept leg'); } const target = { appBundleId, surface: 'app', deadlineAtMs } as const; + // Once the deadline has passed, a step is refused before anything is sent. + const beforeDeadline = async (step: () => Promise): Promise => { + if (deadlineAtMs !== undefined && Date.now() >= deadlineAtMs) { + throw runnerCommandDeadlineSpentError(); + } + return await step(); + }; return Object.freeze({ appBundleId, deadlineAtMs, awaitAlert: async () => - await alertIfPresent( - awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), + await beforeDeadline( + async () => + await alertIfPresent( + awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), + ), ), - readAlert: async () => await alertIfPresent(readAlert(target)), - acceptAlert: async () => await acceptAlert(target), + readAlert: async () => + await beforeDeadline(async () => await alertIfPresent(readAlert(target))), + acceptAlert: async () => await beforeDeadline(async () => await acceptAlert(target)), resolveUrlOwner: async () => - await resolveIosSimulatorDeepLinkBundleId(device, url, { - timeoutMs: capAtDeadline(URL_OWNER_LOOKUP_TIMEOUT_MS, deadlineAtMs), - signal, - }), + await beforeDeadline( + async () => + await resolveIosSimulatorDeepLinkBundleId(device, url, { + timeoutMs: + deadlineAtMs === undefined + ? URL_OWNER_LOOKUP_TIMEOUT_MS + : Math.min(URL_OWNER_LOOKUP_TIMEOUT_MS, deadlineAtMs - Date.now()), + signal, + }), + ), }); } diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts new file mode 100644 index 0000000000..80dbd23afa --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; +import { afterEach, beforeEach, test, vi } from 'vitest'; + +/** A controlled clock: the readiness preflight spends `preflightMs` of it when it answers. */ +const clock = vi.hoisted(() => ({ offsetMs: 0, preflightMs: 0 })); + +vi.mock('../runner-startup-transport.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForRunner: async (...args: Parameters) => { + const response = await actual.waitForRunner(...args); + if (args[2].command === 'uptime') clock.offsetMs += clock.preflightMs; + return response; + }, + }; +}); + +import { executeRunnerExchange } from '../runner-exchange.ts'; +import type { RunnerCommand } from '../runner-contract.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { makeRunnerSession } from './runner-session-fixtures.ts'; +import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; + +const NOW_MS = 1_000_000; +let server: FakeRunnerServer | undefined; + +beforeEach(() => { + clock.offsetMs = 0; + clock.preflightMs = 0; + vi.spyOn(Date, 'now').mockImplementation(() => NOW_MS + clock.offsetMs); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await server?.close(); + server = undefined; +}); + +async function exchange(command: RunnerCommand): Promise { + server = await startFakeRunnerServer({ alert: [{ kind: 'ok', data: { handled: true } }] }); + return await executeRunnerExchange( + IOS_SIMULATOR, + makeRunnerSession({ port: server.port }), + command, + undefined, + 45_000, + async () => {}, + ).catch((error: unknown) => error); +} + +function sentAlerts(): Record[] { + return (server?.requests ?? []) + .filter(({ command }) => command === 'alert') + .map(({ body }) => body); +} + +test('an alert command whose deadline already passed is refused unsent', async () => { + const outcome = await exchange({ + command: 'alert', + action: 'get', + timeoutMs: 10_000, + deadline: { atMs: NOW_MS - 1, minTimeoutMs: 1 }, + }); + + assert.ok(outcome instanceof AppError); + assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); + assert.equal(outcome.details?.dispatched, 'no'); + assert.deepEqual(sentAlerts(), []); +}); + +test('a press the preflight leaves too little time for is not sent', async () => { + clock.preflightMs = 800; + const outcome = await exchange({ + command: 'alert', + action: 'accept', + timeoutMs: 10_000, + deadline: { atMs: NOW_MS + 10_500, minTimeoutMs: 10_000 }, + }); + + assert.ok(outcome instanceof AppError); + assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); + assert.deepEqual(sentAlerts(), []); +}); + +test('the runner timeout is what the deadline leaves after the preflight', async () => { + clock.preflightMs = 800; + const outcome = await exchange({ + command: 'alert', + action: 'get', + timeoutMs: 10_000, + deadline: { atMs: NOW_MS + 5_000, minTimeoutMs: 1 }, + }); + + assert.deepEqual(outcome, { handled: true }); + assert.equal(sentAlerts().length, 1); + assert.equal(sentAlerts()[0]?.timeoutMs, 4_200); + assert.equal('deadline' in (sentAlerts()[0] ?? {}), false, 'the deadline stays on the host'); +}); diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 74e794092c..af5375faca 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -41,6 +41,45 @@ 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; +/** + * An absolute deadline for one command's runner work: `atMs` bounds the runner timeout, and a + * command left less than `minTimeoutMs` is not worth sending. It bounds only that timeout; runner + * startup, the readiness preflight and the HTTP exchange keep their own bounds. + */ +type RunnerCommandDeadline = Readonly<{ atMs: number; minTimeoutMs: number }>; + +/** Why a command was refused unsent: its deadline left less time than it needs. */ +const RUNNER_COMMAND_DEADLINE_SPENT_REASON = 'runner_command_deadline_spent'; + +export function runnerCommandDeadlineSpentError(): AppError { + return new AppError('COMMAND_FAILED', 'The deadline left no time to send this runner command.', { + reason: RUNNER_COMMAND_DEADLINE_SPENT_REASON, + dispatched: 'no', + }); +} + +export function isRunnerCommandDeadlineSpentError(error: unknown): boolean { + return ( + error instanceof AppError && error.details?.reason === RUNNER_COMMAND_DEADLINE_SPENT_REASON + ); +} + +/** + * The payload a command is sent with, computed right before the send: a command with a + * `deadline` gets the time it leaves as its runner `timeoutMs`, and is refused unsent when that + * time is below `minTimeoutMs`. The deadline itself never reaches the runner. + */ +export function bindRunnerCommandDeadline( + command: RunnerCommand, + nowMs: number = Date.now(), +): RunnerCommand { + const { deadline, ...payload } = command; + if (!deadline) return command; + const leftMs = Math.floor(deadline.atMs - nowMs); + if (leftMs <= 0 || leftMs < deadline.minTimeoutMs) throw runnerCommandDeadlineSpentError(); + return { ...payload, timeoutMs: Math.min(payload.timeoutMs ?? leftMs, leftMs) }; +} + /** * 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. @@ -128,6 +167,8 @@ export type RunnerCommand = { durationMs?: number; /** Remaining request budget for runner work that performs bounded XCTest queries. */ timeoutMs?: number; + /** Host-side only, never sent: `bindRunnerCommandDeadline` turns it into `timeoutMs`. */ + deadline?: RunnerCommandDeadline; direction?: ScrollDirection; amount?: number; pixels?: number; diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index dfbdcbc95f..c74fe9f6e3 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, + bindRunnerCommandDeadline, RUNNER_READY_PREFLIGHT_TIMEOUT_MS, withRunnerCommandId, type RunnerCommand, @@ -281,6 +282,8 @@ async function sendRunnerCommandAfterPreflight(params: { if (remainingMs <= 0) { throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs }); } + // After the preflight and before any charge: a command whose deadline is spent is refused unsent. + const payload = bindRunnerCommandDeadline(runnerCommand); const diagnosticData = readOnlyCommand ? { command: runnerCommand.command, @@ -307,20 +310,14 @@ async function sendRunnerCommandAfterPreflight(params: { return await waitForRunner( device, session.endpoint, - runnerCommand, + payload, logPath, remainingMs, session, signal, ); } - return await sendRunnerCommandOnce( - device, - session.endpoint, - runnerCommand, - remainingMs, - signal, - ); + return await sendRunnerCommandOnce(device, session.endpoint, payload, remainingMs, signal); }, diagnosticData, ); diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 9bd0a91a76..3e92069ce6 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. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only while the open request still has time for 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.', + '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. The runner timeouts of these alert steps are capped at that time; runner startup and transport overhead are outside that bound. 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 a162989717..eabcefbdc5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -111,7 +111,7 @@ iOS Simulator launch behavior: - When `open --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. - 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. When the runner reports its main-thread timeout for the tap and the prompt is still up, `open` taps `Open` again. It does so only while the `open` request still has time for a whole tap and the read that checks it; otherwise it fails. Answer it with `alert accept` or `alert dismiss` before continuing. +- `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. When the runner reports its main-thread timeout for the 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; otherwise it fails. The runner timeouts of these steps are capped at that time; runner startup and transport overhead are outside that bound. Answer it with `alert accept` or `alert dismiss` before continuing. - `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`. From be2b6bc94145bd28cfabd7a109a75348e0ec3407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 14:22:00 +0100 Subject: [PATCH 07/12] docs(ios): name the URL-owner lookup cap and pin the unsent press disclosure Co-Authored-By: Claude Opus 5.5 --- .../src/runner/__tests__/runner-exchange-deadline.test.ts | 1 + src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts index 80dbd23afa..6b8305e95f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts @@ -81,6 +81,7 @@ test('a press the preflight leaves too little time for is not sent', async () => assert.ok(outcome instanceof AppError); assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); + assert.equal(outcome.details?.dispatched, 'no'); assert.deepEqual(sentAlerts(), []); }); diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 3e92069ce6..dbf2dacc95 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. 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. The runner timeouts of these alert steps are capped at that time; runner startup and transport overhead are outside that bound. 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.', + '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. The runner timeouts of the alert read and accept steps, and the URL-owner lookup's timeout, are capped at that time; runner startup and transport overhead are outside that bound. 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 eabcefbdc5..ceb7738767 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -111,7 +111,7 @@ iOS Simulator launch behavior: - When `open --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. - 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. When the runner reports its main-thread timeout for the 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; otherwise it fails. The runner timeouts of these steps are capped at that time; runner startup and transport overhead are outside that bound. Answer it with `alert accept` or `alert dismiss` before continuing. +- `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. When the runner reports its main-thread timeout for the 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; otherwise it fails. The runner timeouts of these alert steps, and the URL-owner lookup's timeout, are capped at that time; runner startup and transport overhead are outside that bound. Answer it with `alert accept` or `alert dismiss` before continuing. - `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`. From 1df7a0d342660ea0a7ba2fa1c33d95707bdb596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 14:23:16 +0100 Subject: [PATCH 08/12] fix(ios): escape the open help apostrophe Co-Authored-By: Claude Opus 5.5 --- src/commands/management/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index dbf2dacc95..c740fdecad 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. 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. The runner timeouts of the alert read and accept steps, and the URL-owner lookup's timeout, are capped at that time; runner startup and transport overhead are outside that bound. 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.', + '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. The runner timeouts of the alert read and accept steps, and the URL-owner lookup\'s timeout, are capped at that time; runner startup and transport overhead are outside that bound. 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.", }, From 16a94e54f44d6bb811871897029be9b3ee7eb88c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 14:43:25 +0100 Subject: [PATCH 09/12] test(ios): pin the exact minTimeoutMs edge and narrow the alert deadline doc Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/interactor-types.ts | 6 ++++- .../runner-exchange-deadline.test.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index bb2364076b..f072667862 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -541,7 +541,11 @@ export type Interactor = { */ export type AlertInteractorOptions = { timeoutMs?: number; - /** Absolute time no runner alert command may outlast; each leg caps its own budget at it. */ + /** + * Absolute deadline for each runner alert command's native timeout. Right before the send, after + * the readiness preflight, the timeout becomes the time left; a command left too little is not + * sent. Runner startup and transport overhead are outside this bound. + */ deadlineAtMs?: number; /** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */ signal?: AbortSignal; diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts index 6b8305e95f..ce199f8b31 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts @@ -99,3 +99,25 @@ test('the runner timeout is what the deadline leaves after the preflight', async assert.equal(sentAlerts()[0]?.timeoutMs, 4_200); assert.equal('deadline' in (sentAlerts()[0] ?? {}), false, 'the deadline stays on the host'); }); + +test.each([ + ['exactly the shortest timeout it needs is sent', 10_000, true], + ['1 ms below the shortest timeout it needs is refused unsent', 9_999, false], +] as const)('a press left %s', async (_case, leftMs, sent) => { + const outcome = await exchange({ + command: 'alert', + action: 'accept', + timeoutMs: 10_000, + deadline: { atMs: NOW_MS + leftMs, minTimeoutMs: 10_000 }, + }); + + if (sent) { + assert.deepEqual(outcome, { handled: true }); + assert.equal(sentAlerts()[0]?.timeoutMs, 10_000); + } else { + assert.ok(outcome instanceof AppError); + assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); + assert.equal(outcome.details?.dispatched, 'no'); + assert.deepEqual(sentAlerts(), []); + } +}); From 6382182ac759ad19a55d9bf8902dce39994d7a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 14:47:55 +0100 Subject: [PATCH 10/12] docs(ios): say the alert deadline caps the native timeout Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/interactor-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index f072667862..5a9c07db19 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -543,8 +543,8 @@ export type AlertInteractorOptions = { timeoutMs?: number; /** * Absolute deadline for each runner alert command's native timeout. Right before the send, after - * the readiness preflight, the timeout becomes the time left; a command left too little is not - * sent. Runner startup and transport overhead are outside this bound. + * the readiness preflight, the timeout is capped at the time left; a command left too little is + * not sent. Runner startup and transport overhead are outside this bound. */ deadlineAtMs?: number; /** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */ From b45f2e2ab29dbfc8a8b8f66219c8f25141bdf19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 16:02:07 +0100 Subject: [PATCH 11/12] fix(ios): gate only the launch-confirmation re-press on the open deadline Review of 101a8ce: the per-command deadline also governed the first press, so a first press with less than 10 s of the open budget left was refused unsent where main pressed it. The request deadline now decides only whether to press again, at the one re-press gate (a whole press and its read, 22 s); the first press and every read keep their own runner bounds. This removes the opt-in runner command deadline, its exchange binding and typed refusal, the alert-leg deadline option and the URL-owner lookup cap. A new suite drives the answer through the real alert legs and runner client against a fake runner: an abandoned press is verified through two RUNNER_BUSY resends and pressed again with 49.4 s of a 60 s open left, and a first press with 5 s left is still sent with its full watchdog (refused on 101a8ce). Refs #3861 Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/interactor-types.ts | 6 - packages/platform-apple/src/alert.ts | 18 +- .../src/launch-confirmation.test.ts | 65 +----- .../platform-apple/src/launch-confirmation.ts | 50 ++--- packages/platform-apple/src/open-policy.ts | 4 +- .../launch-confirmation-runner-client.test.ts | 200 ++++++++++++++++++ .../runner-exchange-deadline.test.ts | 123 ----------- .../src/runner/runner-contract.ts | 41 ---- .../src/runner/runner-exchange.ts | 13 +- src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 3 +- 11 files changed, 227 insertions(+), 298 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/launch-confirmation-runner-client.test.ts delete mode 100644 packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 5a9c07db19..8e6cee78fa 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -541,12 +541,6 @@ export type Interactor = { */ export type AlertInteractorOptions = { timeoutMs?: number; - /** - * Absolute deadline for each runner alert command's native timeout. Right before the send, after - * the readiness preflight, the timeout is capped at the time left; a command left too little is - * not sent. Runner startup and transport overhead are outside this bound. - */ - deadlineAtMs?: number; /** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */ signal?: AbortSignal; appBundleId?: string; diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index 9c09f20d8f..baa33228ce 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -51,26 +51,10 @@ function runAppleAlert( return async (action) => (await runMacOsAlertAction(action, { ...target, env })) as Record; } - // A press needs its whole watchdog; a read is worth sending while any time is left. - const deadline = (action: NativeAlertAction, timeoutMs: number) => - options?.deadlineAtMs === undefined - ? {} - : { - deadline: { - atMs: options.deadlineAtMs, - minTimeoutMs: action === 'get' ? 1 : timeoutMs, - }, - }; return async (action, timeoutMs) => (await runAppleRunnerCommand( device, - { - command: 'alert', - action, - appBundleId: options?.appBundleId, - timeoutMs, - ...deadline(action, timeoutMs), - }, + { command: 'alert', action, appBundleId: options?.appBundleId, timeoutMs }, runnerOptions, )) as Record; } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 5a0005b663..e3b73d09a6 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; -import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts'; +import { awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner-client.ts'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -740,66 +740,3 @@ test('abandoned accepts stop once the request cannot hold another press', async expect(acceptAlert).toHaveBeenCalledTimes(2); }); }); - -/** The Apple alert legs over the mocked runner client, as the session interactor binds them. */ -function runnerAlertLegs() { - return { - awaitAlert: async (options) => await awaitAppleAlert(simulator, {}, options, {}), - readAlert: async (options) => await readAppleAlert(simulator, {}, options, {}), - acceptAlert: async (options) => await actOnAppleAlert(simulator, {}, 'accept', options, {}), - } satisfies Pick; -} - -test('a deadline spent by the press sends no verification read and keeps its unknown effect', async () => { - const runner = vi.mocked(runAppleRunnerCommand); - await onFakeClock(async () => { - const deadlineAtMs = Date.now() + 12_000; - const sent: unknown[] = []; - runner.mockImplementation(async (_device, command) => { - sent.push(command); - if ((command as { action?: string }).action !== 'accept') return CONFIRMATION; - vi.advanceTimersByTime(12_000); - throw runnerMainThreadTimeout(); - }); - vi.mocked(resolveIosSimulatorDeepLinkBundleId).mockResolvedValueOnce('com.example.app'); - - await expect( - answerSimulatorLaunchConfirmation( - simulator, - { url: 'example://automation', appBundleId: 'com.example.app' }, - Promise.resolve(runnerAlertLegs() as Interactor), - new AbortController().signal, - deadlineAtMs, - ), - ).resolves.toEqual({ outcome: 'unreadable', step: 'alert-accept' }); - expect(sent).toEqual([ - expect.objectContaining({ - action: 'get', - timeoutMs: 3_000, - deadline: { atMs: deadlineAtMs, minTimeoutMs: 1 }, - }), - expect.objectContaining({ - action: 'accept', - timeoutMs: DEFAULT_ALERT_TIMEOUT_MS, - deadline: { atMs: deadlineAtMs, minTimeoutMs: DEFAULT_ALERT_TIMEOUT_MS }, - }), - ]); - }).finally(() => runner.mockReset()); -}); - -test('a press the runner client refused unsent leaves the confirmation unanswered unread', async () => { - const acceptAlert = vi.fn(async () => { - throw new AppError('COMMAND_FAILED', 'The deadline left no time to send this runner command.', { - reason: 'runner_command_deadline_spent', - dispatched: 'no', - }); - }); - const { port: device } = port(async () => CONFIRMATION, { - acceptAlert, - deadlineAtMs: Date.now() + 60_000, - }); - - await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT); - expect(acceptAlert).toHaveBeenCalledOnce(); - expect(device.readAlert).not.toHaveBeenCalled(); -}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index e3dbb7f21d..877a1c68e5 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -6,11 +6,7 @@ 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 { - isRunnerCommandDeadlineSpentError, - RUNNER_READY_PREFLIGHT_TIMEOUT_MS, - runnerCommandDeadlineSpentError, -} from './runner/runner-contract.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'; @@ -66,8 +62,8 @@ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: stri export type LaunchConfirmationPort = Readonly<{ appBundleId: string; /** - * When the enclosing request must have its result. Once it passes, no step is sent; runner - * timeouts are capped at it; and only a request with one has time to press `Open` again. + * 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. */ @@ -140,11 +136,6 @@ async function acceptConfirmation( (error: unknown) => ({ error }), ); if (!failure) return accepted(); - if (isRunnerCommandDeadlineSpentError(failure.error)) { - // Refused unsent: nothing was pressed, so the confirmation just seen is still up. - reportUnanswered('alert-still-present', { pressSent: false }); - return { outcome: 'unanswered', reason: 'alert-still-present' }; - } const attempt = await verifyFailedAcceptance(port, original, failure.error); if (attempt.outcome !== 'unanswered') return attempt; const remainingMs = timeLeft(port.deadlineAtMs); @@ -261,37 +252,20 @@ export function createLaunchConfirmationPort( if (!awaitAlert || !readAlert || !acceptAlert) { throw invalidRuntimeContract('Apple interactor has no alert wait, read or accept leg'); } - const target = { appBundleId, surface: 'app', deadlineAtMs } as const; - // Once the deadline has passed, a step is refused before anything is sent. - const beforeDeadline = async (step: () => Promise): Promise => { - if (deadlineAtMs !== undefined && Date.now() >= deadlineAtMs) { - throw runnerCommandDeadlineSpentError(); - } - return await step(); - }; + const target = { appBundleId, surface: 'app' } as const; return Object.freeze({ appBundleId, deadlineAtMs, awaitAlert: async () => - await beforeDeadline( - async () => - await alertIfPresent( - awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), - ), + await alertIfPresent( + awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }), ), - readAlert: async () => - await beforeDeadline(async () => await alertIfPresent(readAlert(target))), - acceptAlert: async () => await beforeDeadline(async () => await acceptAlert(target)), + readAlert: async () => await alertIfPresent(readAlert(target)), + acceptAlert: async () => await acceptAlert(target), resolveUrlOwner: async () => - await beforeDeadline( - async () => - await resolveIosSimulatorDeepLinkBundleId(device, url, { - timeoutMs: - deadlineAtMs === undefined - ? URL_OWNER_LOOKUP_TIMEOUT_MS - : Math.min(URL_OWNER_LOOKUP_TIMEOUT_MS, deadlineAtMs - Date.now()), - signal, - }), - ), + await resolveIosSimulatorDeepLinkBundleId(device, url, { + timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS, + signal, + }), }); } diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index 0c939d96da..54572a600d 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -198,8 +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, capped at the request's deadline (an accept the - * runner watchdog abandoned is pressed again only while that deadline holds another press), 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__/launch-confirmation-runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/launch-confirmation-runner-client.test.ts new file mode 100644 index 0000000000..359332c09c --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/launch-confirmation-runner-client.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/__tests__/runner-exchange-deadline.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts deleted file mode 100644 index ce199f8b31..0000000000 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange-deadline.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import assert from 'node:assert/strict'; -import { AppError } from '@agent-device/kernel/errors'; -import { afterEach, beforeEach, test, vi } from 'vitest'; - -/** A controlled clock: the readiness preflight spends `preflightMs` of it when it answers. */ -const clock = vi.hoisted(() => ({ offsetMs: 0, preflightMs: 0 })); - -vi.mock('../runner-startup-transport.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - waitForRunner: async (...args: Parameters) => { - const response = await actual.waitForRunner(...args); - if (args[2].command === 'uptime') clock.offsetMs += clock.preflightMs; - return response; - }, - }; -}); - -import { executeRunnerExchange } from '../runner-exchange.ts'; -import type { RunnerCommand } from '../runner-contract.ts'; -import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { makeRunnerSession } from './runner-session-fixtures.ts'; -import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; - -const NOW_MS = 1_000_000; -let server: FakeRunnerServer | undefined; - -beforeEach(() => { - clock.offsetMs = 0; - clock.preflightMs = 0; - vi.spyOn(Date, 'now').mockImplementation(() => NOW_MS + clock.offsetMs); -}); - -afterEach(async () => { - vi.restoreAllMocks(); - await server?.close(); - server = undefined; -}); - -async function exchange(command: RunnerCommand): Promise { - server = await startFakeRunnerServer({ alert: [{ kind: 'ok', data: { handled: true } }] }); - return await executeRunnerExchange( - IOS_SIMULATOR, - makeRunnerSession({ port: server.port }), - command, - undefined, - 45_000, - async () => {}, - ).catch((error: unknown) => error); -} - -function sentAlerts(): Record[] { - return (server?.requests ?? []) - .filter(({ command }) => command === 'alert') - .map(({ body }) => body); -} - -test('an alert command whose deadline already passed is refused unsent', async () => { - const outcome = await exchange({ - command: 'alert', - action: 'get', - timeoutMs: 10_000, - deadline: { atMs: NOW_MS - 1, minTimeoutMs: 1 }, - }); - - assert.ok(outcome instanceof AppError); - assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); - assert.equal(outcome.details?.dispatched, 'no'); - assert.deepEqual(sentAlerts(), []); -}); - -test('a press the preflight leaves too little time for is not sent', async () => { - clock.preflightMs = 800; - const outcome = await exchange({ - command: 'alert', - action: 'accept', - timeoutMs: 10_000, - deadline: { atMs: NOW_MS + 10_500, minTimeoutMs: 10_000 }, - }); - - assert.ok(outcome instanceof AppError); - assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); - assert.equal(outcome.details?.dispatched, 'no'); - assert.deepEqual(sentAlerts(), []); -}); - -test('the runner timeout is what the deadline leaves after the preflight', async () => { - clock.preflightMs = 800; - const outcome = await exchange({ - command: 'alert', - action: 'get', - timeoutMs: 10_000, - deadline: { atMs: NOW_MS + 5_000, minTimeoutMs: 1 }, - }); - - assert.deepEqual(outcome, { handled: true }); - assert.equal(sentAlerts().length, 1); - assert.equal(sentAlerts()[0]?.timeoutMs, 4_200); - assert.equal('deadline' in (sentAlerts()[0] ?? {}), false, 'the deadline stays on the host'); -}); - -test.each([ - ['exactly the shortest timeout it needs is sent', 10_000, true], - ['1 ms below the shortest timeout it needs is refused unsent', 9_999, false], -] as const)('a press left %s', async (_case, leftMs, sent) => { - const outcome = await exchange({ - command: 'alert', - action: 'accept', - timeoutMs: 10_000, - deadline: { atMs: NOW_MS + leftMs, minTimeoutMs: 10_000 }, - }); - - if (sent) { - assert.deepEqual(outcome, { handled: true }); - assert.equal(sentAlerts()[0]?.timeoutMs, 10_000); - } else { - assert.ok(outcome instanceof AppError); - assert.equal(outcome.details?.reason, 'runner_command_deadline_spent'); - assert.equal(outcome.details?.dispatched, 'no'); - assert.deepEqual(sentAlerts(), []); - } -}); diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index af5375faca..74e794092c 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -41,45 +41,6 @@ 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; -/** - * An absolute deadline for one command's runner work: `atMs` bounds the runner timeout, and a - * command left less than `minTimeoutMs` is not worth sending. It bounds only that timeout; runner - * startup, the readiness preflight and the HTTP exchange keep their own bounds. - */ -type RunnerCommandDeadline = Readonly<{ atMs: number; minTimeoutMs: number }>; - -/** Why a command was refused unsent: its deadline left less time than it needs. */ -const RUNNER_COMMAND_DEADLINE_SPENT_REASON = 'runner_command_deadline_spent'; - -export function runnerCommandDeadlineSpentError(): AppError { - return new AppError('COMMAND_FAILED', 'The deadline left no time to send this runner command.', { - reason: RUNNER_COMMAND_DEADLINE_SPENT_REASON, - dispatched: 'no', - }); -} - -export function isRunnerCommandDeadlineSpentError(error: unknown): boolean { - return ( - error instanceof AppError && error.details?.reason === RUNNER_COMMAND_DEADLINE_SPENT_REASON - ); -} - -/** - * The payload a command is sent with, computed right before the send: a command with a - * `deadline` gets the time it leaves as its runner `timeoutMs`, and is refused unsent when that - * time is below `minTimeoutMs`. The deadline itself never reaches the runner. - */ -export function bindRunnerCommandDeadline( - command: RunnerCommand, - nowMs: number = Date.now(), -): RunnerCommand { - const { deadline, ...payload } = command; - if (!deadline) return command; - const leftMs = Math.floor(deadline.atMs - nowMs); - if (leftMs <= 0 || leftMs < deadline.minTimeoutMs) throw runnerCommandDeadlineSpentError(); - return { ...payload, timeoutMs: Math.min(payload.timeoutMs ?? leftMs, leftMs) }; -} - /** * 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. @@ -167,8 +128,6 @@ export type RunnerCommand = { durationMs?: number; /** Remaining request budget for runner work that performs bounded XCTest queries. */ timeoutMs?: number; - /** Host-side only, never sent: `bindRunnerCommandDeadline` turns it into `timeoutMs`. */ - deadline?: RunnerCommandDeadline; direction?: ScrollDirection; amount?: number; pixels?: number; diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index c74fe9f6e3..dfbdcbc95f 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -12,7 +12,6 @@ import { decodeRunnerResponseBody, isRunnerResponseOk, readRunnerResponseData, - bindRunnerCommandDeadline, RUNNER_READY_PREFLIGHT_TIMEOUT_MS, withRunnerCommandId, type RunnerCommand, @@ -282,8 +281,6 @@ async function sendRunnerCommandAfterPreflight(params: { if (remainingMs <= 0) { throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs }); } - // After the preflight and before any charge: a command whose deadline is spent is refused unsent. - const payload = bindRunnerCommandDeadline(runnerCommand); const diagnosticData = readOnlyCommand ? { command: runnerCommand.command, @@ -310,14 +307,20 @@ async function sendRunnerCommandAfterPreflight(params: { return await waitForRunner( device, session.endpoint, - payload, + runnerCommand, logPath, remainingMs, session, signal, ); } - return await sendRunnerCommandOnce(device, session.endpoint, payload, remainingMs, signal); + return await sendRunnerCommandOnce( + device, + session.endpoint, + runnerCommand, + remainingMs, + signal, + ); }, diagnosticData, ); diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index c740fdecad..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. 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. The runner timeouts of the alert read and accept steps, and the URL-owner lookup\'s timeout, are capped at that time; runner startup and transport overhead are outside that bound. 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.', + '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 ceb7738767..0b6bd81a50 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -111,7 +111,8 @@ iOS Simulator launch behavior: - When `open --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. - 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. When the runner reports its main-thread timeout for the 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; otherwise it fails. The runner timeouts of these alert steps, and the URL-owner lookup's timeout, are capped at that time; runner startup and transport overhead are outside that bound. Answer it with `alert accept` or `alert dismiss` before continuing. +- `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`. From e3fe452aedd7df6be2e8c21e6cf380c2e97f960c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 28 Oct 2026 16:10:27 +0100 Subject: [PATCH 12/12] test(ios): name the cross-module launch-confirmation runner suite for the runner package index Co-Authored-By: Claude Opus 5.5 --- ...on-runner-client.test.ts => index-launch-confirmation.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/platform-apple/src/runner/__tests__/{launch-confirmation-runner-client.test.ts => index-launch-confirmation.test.ts} (100%) diff --git a/packages/platform-apple/src/runner/__tests__/launch-confirmation-runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/index-launch-confirmation.test.ts similarity index 100% rename from packages/platform-apple/src/runner/__tests__/launch-confirmation-runner-client.test.ts rename to packages/platform-apple/src/runner/__tests__/index-launch-confirmation.test.ts