Skip to content

Commit b45f2e2

Browse files
thymikeeclaude
andcommitted
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 <[email protected]>
1 parent 6382182 commit b45f2e2

11 files changed

Lines changed: 227 additions & 298 deletions

File tree

‎packages/contracts/src/interactor-types.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -541,12 +541,6 @@ export type Interactor = {
541541
*/
542542
export type AlertInteractorOptions = {
543543
timeoutMs?: number;
544-
/**
545-
* Absolute deadline for each runner alert command's native timeout. Right before the send, after
546-
* the readiness preflight, the timeout is capped at the time left; a command left too little is
547-
* not sent. Runner startup and transport overhead are outside this bound.
548-
*/
549-
deadlineAtMs?: number;
550544
/** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */
551545
signal?: AbortSignal;
552546
appBundleId?: string;

‎packages/platform-apple/src/alert.ts‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,26 +51,10 @@ function runAppleAlert(
5151
return async (action) =>
5252
(await runMacOsAlertAction(action, { ...target, env })) as Record<string, unknown>;
5353
}
54-
// A press needs its whole watchdog; a read is worth sending while any time is left.
55-
const deadline = (action: NativeAlertAction, timeoutMs: number) =>
56-
options?.deadlineAtMs === undefined
57-
? {}
58-
: {
59-
deadline: {
60-
atMs: options.deadlineAtMs,
61-
minTimeoutMs: action === 'get' ? 1 : timeoutMs,
62-
},
63-
};
6454
return async (action, timeoutMs) =>
6555
(await runAppleRunnerCommand(
6656
device,
67-
{
68-
command: 'alert',
69-
action,
70-
appBundleId: options?.appBundleId,
71-
timeoutMs,
72-
...deadline(action, timeoutMs),
73-
},
57+
{ command: 'alert', action, appBundleId: options?.appBundleId, timeoutMs },
7458
runnerOptions,
7559
)) as Record<string, unknown>;
7660
}

‎packages/platform-apple/src/launch-confirmation.test.ts‎

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test, vi } from 'vitest';
22
import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract';
3-
import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts';
3+
import { awaitAppleAlert, readAppleAlert } from './alert.ts';
44
import { runAppleRunnerCommand } from './core/runner-client.ts';
55
import type { Interactor } from '@agent-device/contracts/interactor-types';
66
import type { DeviceInfo } from '@agent-device/kernel/device';
@@ -740,66 +740,3 @@ test('abandoned accepts stop once the request cannot hold another press', async
740740
expect(acceptAlert).toHaveBeenCalledTimes(2);
741741
});
742742
});
743-
744-
/** The Apple alert legs over the mocked runner client, as the session interactor binds them. */
745-
function runnerAlertLegs() {
746-
return {
747-
awaitAlert: async (options) => await awaitAppleAlert(simulator, {}, options, {}),
748-
readAlert: async (options) => await readAppleAlert(simulator, {}, options, {}),
749-
acceptAlert: async (options) => await actOnAppleAlert(simulator, {}, 'accept', options, {}),
750-
} satisfies Pick<Interactor, 'awaitAlert' | 'readAlert' | 'acceptAlert'>;
751-
}
752-
753-
test('a deadline spent by the press sends no verification read and keeps its unknown effect', async () => {
754-
const runner = vi.mocked(runAppleRunnerCommand);
755-
await onFakeClock(async () => {
756-
const deadlineAtMs = Date.now() + 12_000;
757-
const sent: unknown[] = [];
758-
runner.mockImplementation(async (_device, command) => {
759-
sent.push(command);
760-
if ((command as { action?: string }).action !== 'accept') return CONFIRMATION;
761-
vi.advanceTimersByTime(12_000);
762-
throw runnerMainThreadTimeout();
763-
});
764-
vi.mocked(resolveIosSimulatorDeepLinkBundleId).mockResolvedValueOnce('com.example.app');
765-
766-
await expect(
767-
answerSimulatorLaunchConfirmation(
768-
simulator,
769-
{ url: 'example://automation', appBundleId: 'com.example.app' },
770-
Promise.resolve(runnerAlertLegs() as Interactor),
771-
new AbortController().signal,
772-
deadlineAtMs,
773-
),
774-
).resolves.toEqual({ outcome: 'unreadable', step: 'alert-accept' });
775-
expect(sent).toEqual([
776-
expect.objectContaining({
777-
action: 'get',
778-
timeoutMs: 3_000,
779-
deadline: { atMs: deadlineAtMs, minTimeoutMs: 1 },
780-
}),
781-
expect.objectContaining({
782-
action: 'accept',
783-
timeoutMs: DEFAULT_ALERT_TIMEOUT_MS,
784-
deadline: { atMs: deadlineAtMs, minTimeoutMs: DEFAULT_ALERT_TIMEOUT_MS },
785-
}),
786-
]);
787-
}).finally(() => runner.mockReset());
788-
});
789-
790-
test('a press the runner client refused unsent leaves the confirmation unanswered unread', async () => {
791-
const acceptAlert = vi.fn(async () => {
792-
throw new AppError('COMMAND_FAILED', 'The deadline left no time to send this runner command.', {
793-
reason: 'runner_command_deadline_spent',
794-
dispatched: 'no',
795-
});
796-
});
797-
const { port: device } = port(async () => CONFIRMATION, {
798-
acceptAlert,
799-
deadlineAtMs: Date.now() + 60_000,
800-
});
801-
802-
await expect(answerLaunchConfirmation(device)).resolves.toEqual(STILL_PRESENT);
803-
expect(acceptAlert).toHaveBeenCalledOnce();
804-
expect(device.readAlert).not.toHaveBeenCalled();
805-
});

‎packages/platform-apple/src/launch-confirmation.ts‎

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
66
import { AppError } from '@agent-device/kernel/errors';
77
import { alertIfPresent } from './alert.ts';
88
import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts';
9-
import {
10-
isRunnerCommandDeadlineSpentError,
11-
RUNNER_READY_PREFLIGHT_TIMEOUT_MS,
12-
runnerCommandDeadlineSpentError,
13-
} from './runner/runner-contract.ts';
9+
import { RUNNER_READY_PREFLIGHT_TIMEOUT_MS } from './runner/runner-contract.ts';
1410
import { isRunnerMainThreadTimeoutError } from './runner/runner-error-classification.ts';
1511

1612
export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app';
@@ -66,8 +62,8 @@ export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: stri
6662
export type LaunchConfirmationPort = Readonly<{
6763
appBundleId: string;
6864
/**
69-
* When the enclosing request must have its result. Once it passes, no step is sent; runner
70-
* timeouts are capped at it; and only a request with one has time to press `Open` again.
65+
* When the enclosing request must have its result. It decides only whether `Open` is pressed
66+
* again; the first press and every read keep their own runner bounds.
7167
*/
7268
deadlineAtMs: number | undefined;
7369
/** Initial presentation wait, or `undefined` when no alert appears within its window. */
@@ -140,11 +136,6 @@ async function acceptConfirmation(
140136
(error: unknown) => ({ error }),
141137
);
142138
if (!failure) return accepted();
143-
if (isRunnerCommandDeadlineSpentError(failure.error)) {
144-
// Refused unsent: nothing was pressed, so the confirmation just seen is still up.
145-
reportUnanswered('alert-still-present', { pressSent: false });
146-
return { outcome: 'unanswered', reason: 'alert-still-present' };
147-
}
148139
const attempt = await verifyFailedAcceptance(port, original, failure.error);
149140
if (attempt.outcome !== 'unanswered') return attempt;
150141
const remainingMs = timeLeft(port.deadlineAtMs);
@@ -261,37 +252,20 @@ export function createLaunchConfirmationPort(
261252
if (!awaitAlert || !readAlert || !acceptAlert) {
262253
throw invalidRuntimeContract('Apple interactor has no alert wait, read or accept leg');
263254
}
264-
const target = { appBundleId, surface: 'app', deadlineAtMs } as const;
265-
// Once the deadline has passed, a step is refused before anything is sent.
266-
const beforeDeadline = async <T>(step: () => Promise<T>): Promise<T> => {
267-
if (deadlineAtMs !== undefined && Date.now() >= deadlineAtMs) {
268-
throw runnerCommandDeadlineSpentError();
269-
}
270-
return await step();
271-
};
255+
const target = { appBundleId, surface: 'app' } as const;
272256
return Object.freeze({
273257
appBundleId,
274258
deadlineAtMs,
275259
awaitAlert: async () =>
276-
await beforeDeadline(
277-
async () =>
278-
await alertIfPresent(
279-
awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }),
280-
),
260+
await alertIfPresent(
261+
awaitAlert({ ...target, timeoutMs: LAUNCH_CONFIRMATION_PRESENTATION_TIMEOUT_MS }),
281262
),
282-
readAlert: async () =>
283-
await beforeDeadline(async () => await alertIfPresent(readAlert(target))),
284-
acceptAlert: async () => await beforeDeadline(async () => await acceptAlert(target)),
263+
readAlert: async () => await alertIfPresent(readAlert(target)),
264+
acceptAlert: async () => await acceptAlert(target),
285265
resolveUrlOwner: async () =>
286-
await beforeDeadline(
287-
async () =>
288-
await resolveIosSimulatorDeepLinkBundleId(device, url, {
289-
timeoutMs:
290-
deadlineAtMs === undefined
291-
? URL_OWNER_LOOKUP_TIMEOUT_MS
292-
: Math.min(URL_OWNER_LOOKUP_TIMEOUT_MS, deadlineAtMs - Date.now()),
293-
signal,
294-
}),
295-
),
266+
await resolveIosSimulatorDeepLinkBundleId(device, url, {
267+
timeoutMs: URL_OWNER_LOOKUP_TIMEOUT_MS,
268+
signal,
269+
}),
296270
});
297271
}

‎packages/platform-apple/src/open-policy.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,8 @@ export async function settleAppleOpen(
198198
* process is a launch that was lost, so the URL is handed over again and read once more.
199199
*
200200
* Every step keeps the bound it already had: `openurl` at `IOS_SIMULATOR_OPENURL_TIMEOUT_MS`, the
201-
* read and accept at the runner's alert timeouts, capped at the request's deadline (an accept the
202-
* runner watchdog abandoned is pressed again only while that deadline holds another press), and the observation at the discovery deadline and
201+
* read and accept at the runner's alert timeouts (an accept the runner watchdog abandoned is pressed
202+
* again only while the request's deadline holds another press and its read), and the observation at the discovery deadline and
203203
* the bridge's own launch-transition windows. Nothing here adds an unbounded wait.
204204
*/
205205
async function answerHeldLaunch(

0 commit comments

Comments
 (0)