Skip to content

Commit dcffeaf

Browse files
committed
fix(apple): keep the app-list conversion inside the owner lookup budget and surface its timeout
1 parent d2c7905 commit dcffeaf

3 files changed

Lines changed: 116 additions & 63 deletions

File tree

‎packages/platform-apple/src/core/app-resolution.ts‎

Lines changed: 49 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import path from 'node:path';
22
import { hostFileStat } from '@agent-device/host-kit/host-file';
33
import { fileURLToPath } from 'node:url';
44
import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
5-
import { AppError } from '@agent-device/kernel/errors';
5+
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
6+
import { isCommandTimeoutError } from '@agent-device/host-kit/command';
67
import type { AppsFilter } from '@agent-device/contracts/device';
78
import {
89
createAppResolutionCache,
@@ -247,71 +248,31 @@ async function listSimulatorApps(
247248
}));
248249
}
249250

251+
type SimulatorAppListRecord = Record<
252+
string,
253+
{
254+
ApplicationType?: string;
255+
Bundle?: string;
256+
CFBundleDisplayName?: string;
257+
CFBundleName?: string;
258+
Path?: string;
259+
}
260+
>;
261+
250262
async function listSimulatorAppMetadata(
251263
device: DeviceInfo,
252264
options?: SimulatorAppListOptions,
253265
): Promise<SimulatorAppMetadata[]> {
266+
const remaining = remainingLookupBudget(options ?? {});
254267
const result = await runSimctlForDevice(device, ['listapps', device.id], {
255268
allowFailure: true,
256-
timeoutMs: options?.timeoutMs,
257-
signal: options?.signal,
269+
...remaining(),
258270
});
259-
const stdout = result.stdout as string;
260-
const trimmed = stdout.trim();
261-
if (!trimmed) return [];
262-
263-
let parsed: Record<
264-
string,
265-
{
266-
ApplicationType?: string;
267-
Bundle?: string;
268-
CFBundleDisplayName?: string;
269-
CFBundleName?: string;
270-
Path?: string;
271-
}
272-
> | null = null;
273-
if (trimmed.startsWith('{')) {
274-
try {
275-
parsed = JSON.parse(trimmed) as Record<
276-
string,
277-
{
278-
ApplicationType?: string;
279-
Bundle?: string;
280-
CFBundleDisplayName?: string;
281-
CFBundleName?: string;
282-
Path?: string;
283-
}
284-
>;
285-
} catch {
286-
parsed = null;
287-
}
288-
}
289-
290-
if (!parsed && trimmed.startsWith('{')) {
291-
try {
292-
const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], {
293-
allowFailure: true,
294-
stdin: trimmed,
295-
timeoutMs: options?.timeoutMs,
296-
signal: options?.signal,
297-
});
298-
if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
299-
parsed = JSON.parse(converted.stdout) as Record<
300-
string,
301-
{
302-
ApplicationType?: string;
303-
Bundle?: string;
304-
CFBundleDisplayName?: string;
305-
CFBundleName?: string;
306-
Path?: string;
307-
}
308-
>;
309-
}
310-
} catch {
311-
parsed = null;
312-
}
313-
}
271+
const trimmed = (result.stdout as string).trim();
272+
if (!trimmed.startsWith('{')) return [];
314273

274+
const parsed =
275+
parseSimulatorAppList(trimmed) ?? (await convertSimulatorAppList(trimmed, remaining()));
315276
if (!parsed) return [];
316277
return Object.entries(parsed).map(([bundleId, info]) => {
317278
const appPath = resolveSimulatorAppPath(info);
@@ -324,6 +285,36 @@ async function listSimulatorAppMetadata(
324285
});
325286
}
326287

288+
function parseSimulatorAppList(text: string): SimulatorAppListRecord | null {
289+
try {
290+
return JSON.parse(text) as SimulatorAppListRecord;
291+
} catch {
292+
return null;
293+
}
294+
}
295+
296+
/**
297+
* `simctl listapps` prints an OpenStep plist on some Xcode versions; `plutil` converts it. A
298+
* timeout or abort of that conversion is the lookup's failure, not an empty app list.
299+
*/
300+
async function convertSimulatorAppList(
301+
text: string,
302+
options: SimulatorAppListOptions,
303+
): Promise<SimulatorAppListRecord | null> {
304+
try {
305+
const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], {
306+
allowFailure: true,
307+
stdin: text,
308+
...options,
309+
});
310+
if (converted.exitCode !== 0 || !converted.stdout.trim().startsWith('{')) return null;
311+
return JSON.parse(converted.stdout) as SimulatorAppListRecord;
312+
} catch (error) {
313+
if (isCommandTimeoutError(error) || isRequestCanceledError(error)) throw error;
314+
return null;
315+
}
316+
}
317+
327318
function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined {
328319
if (info.Path) return info.Path;
329320
if (!info.Bundle) return undefined;

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createLaunchConfirmationPort,
1515
LAUNCH_CONFIRMATION_FOREIGN_APP_REASON,
1616
type LaunchConfirmationPort,
17+
URL_OWNER_LOOKUP_TIMEOUT_MS,
1718
} from './launch-confirmation.ts';
1819

1920
vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
@@ -195,10 +196,19 @@ test.each([
195196

196197
/**
197198
* A CoreSimulator that never answers: each spawn settles only when its exec timeout fires or its
198-
* signal aborts, the way `exec.ts` ends a wedged child.
199+
* signal aborts, the way `exec.ts` ends a wedged child. `answers` lets named spawns answer at once,
200+
* so the wedge can sit behind a listing that succeeded.
199201
*/
200-
function wedgedCoreSimulator(): AppleToolProvider & { spawns: () => number } {
202+
function wedgedCoreSimulator(
203+
answers: Readonly<Record<string, string>> = {},
204+
): AppleToolProvider & { spawns: () => number } {
201205
let spawns = 0;
206+
const answered = (key: string): ExecResult | undefined => {
207+
const stdout = answers[key];
208+
if (stdout === undefined) return undefined;
209+
spawns += 1;
210+
return { stdout, stderr: '', exitCode: 0 } as ExecResult;
211+
};
202212
const hang = async (options?: ExecOptions): Promise<ExecResult> =>
203213
await new Promise((_resolve, reject) => {
204214
spawns += 1;
@@ -220,13 +230,65 @@ function wedgedCoreSimulator(): AppleToolProvider & { spawns: () => number } {
220230
});
221231
return {
222232
whichCommand: async () => true,
223-
runCommand: async (_cmd, _args, options) => await hang(options),
224-
simctl: { run: async (_args, options) => await hang(options) },
233+
runCommand: async (cmd, args, options) =>
234+
answered(`${cmd} ${args[args.length - 1]}`) ?? (await hang(options)),
235+
simctl: { run: async (args, options) => answered(args[0] ?? '') ?? (await hang(options)) },
225236
devicectl: { run: async (_args, options) => await hang(options) },
226237
spawns: () => spawns,
227238
};
228239
}
229240

241+
const LISTED_APP = JSON.stringify({
242+
'com.example.app': {
243+
ApplicationType: 'User',
244+
Path: '/apps/Example.app',
245+
CFBundleName: 'Example',
246+
},
247+
});
248+
249+
test.each([
250+
['one Info.plist read hangs after the app list answered', { listapps: LISTED_APP }],
251+
[
252+
'the app list answered in a format only plutil can convert, and that conversion hangs',
253+
{ listapps: '{ "com.example.app" = { ApplicationType = User; }; }' },
254+
],
255+
])(
256+
'the owner lookup is bounded as a whole: %s and the open is left unanswered within its budget',
257+
async (_case, answers) => {
258+
// Only the clock is faked: the lookup stats the Info.plist on the real file system before it
259+
// spawns plutil, and that I/O must complete before the budget is advanced past.
260+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
261+
vi.mocked(emitDiagnostic).mockClear();
262+
try {
263+
const { interactor, acceptAlert } = confirmationRunner();
264+
const coreSimulator = wedgedCoreSimulator(answers);
265+
let answered: unknown = 'pending';
266+
const answer = withAppleToolProvider(coreSimulator, async () =>
267+
answerSimulatorLaunchConfirmation(
268+
simulator,
269+
{ url: 'example://automation', appBundleId: 'com.example.app' },
270+
Promise.resolve(interactor),
271+
new AbortController().signal,
272+
).then((value) => (answered = value)),
273+
);
274+
275+
while (coreSimulator.spawns() < 2) await new Promise((resolve) => setImmediate(resolve));
276+
await vi.advanceTimersByTimeAsync(URL_OWNER_LOOKUP_TIMEOUT_MS + 1);
277+
expect(answered).toBeUndefined();
278+
await answer;
279+
expect(coreSimulator.spawns()).toBe(2);
280+
expect(acceptAlert).not.toHaveBeenCalled();
281+
expect(emitDiagnostic).toHaveBeenCalledWith(
282+
expect.objectContaining({
283+
data: expect.objectContaining({ reason: 'step-failed', step: 'url-owner' }),
284+
}),
285+
);
286+
} finally {
287+
vi.useRealTimers();
288+
}
289+
},
290+
);
291+
230292
function confirmationRunner() {
231293
const acceptAlert = vi.fn(async () => ({}));
232294
const interactor = { readAlert: async () => CONFIRMATION, acceptAlert } as unknown as Interactor;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const LAUNCH_CONFIRMATION_TITLE = /^Open in [“"].+[”"]\?$/u;
2323
* the same bound, well inside `IOS_APP_LAUNCH_TIMEOUT_MS`, so a wedged CoreSimulator leaves the open
2424
* unanswered instead of holding it.
2525
*/
26-
const URL_OWNER_LOOKUP_TIMEOUT_MS = 10_000;
26+
export const URL_OWNER_LOOKUP_TIMEOUT_MS = 10_000;
2727

2828
/** A custom-scheme launch URL SpringBoard may hold for the session app it was checked for. */
2929
export type LaunchConfirmationTarget = Readonly<{ url: string; appBundleId: string }>;

0 commit comments

Comments
 (0)