Skip to content

Commit 89e2f86

Browse files
committed
fix(runtime): resolve each lazily imported platform module exactly once
The application-tools ports each opened their own `import(...)` of the same specifier — seven of them for `@agent-device/platform-apple/runner/operations` alone. In production those duplicates are equivalent, because the loader caches. Under Vitest they are not: while a `vi.mock` factory is still awaiting `importOriginal()`, a second dynamic import of that id resolves to the UNMOCKED module. The open path makes that overlap routine — it deliberately leaves the iOS runner prewarm unawaited — so a unit test that mocks the runner could still reach the real one. `session-open-runtime.test.ts` did: its two iOS-simulator cases scheduled two prewarms, the second one bypassed the mock, and the real local XCTest runner started for device `sim-1`. Its stale-process cleanup then spawned `pkill -f xcodebuild...session-sim-1-[0-9]` against the developer's own process table, which the hermetic-signal guard refused and reported against whichever test happened to be running when it landed — the unrelated `open --metro-port alone stays host-ambiguous on a physical Android device`. Each specifier now has one memoized loader, and the ports reach their module only through it, so the second resolution the escape needs no longer exists. Fixes #2314
1 parent 1e65590 commit 89e2f86

4 files changed

Lines changed: 113 additions & 29 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect, test, vi } from 'vitest';
2+
import { createAppleApplicationTools } from '../platform-runtime-apple-application-tools.ts';
3+
4+
const detachIosSimulatorRunnerSessionsForShutdown = vi.hoisted(() => vi.fn(async () => 0));
5+
const stopAllIosRunnerSessions = vi.hoisted(() => vi.fn(async () => {}));
6+
7+
// The factory awaits the real Apple runner graph on purpose: that wait is what let a second,
8+
// concurrent dynamic import of this specifier overtake the still-unresolved mock and hand the
9+
// caller the UNMOCKED module (#2314).
10+
vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => {
11+
const actual =
12+
await importOriginal<typeof import('@agent-device/platform-apple/runner/operations')>();
13+
return { ...actual, detachIosSimulatorRunnerSessionsForShutdown, stopAllIosRunnerSessions };
14+
});
15+
16+
// Two ports of these tools run at once whenever the open path leaves its runner prewarm
17+
// unawaited, so every port must reach the runner module through the one memoized loader. A port
18+
// that opens its own `import(...)` resolves the specifier a second time, and a unit test that
19+
// loses the mock this way starts the real local XCTest runner — whose stale-process cleanup
20+
// `pkill`s xcodebuild on the developer's host, failing whichever test is running when it lands.
21+
test('concurrent runner ports share one module resolution, so the mock always applies', async () => {
22+
const tools = createAppleApplicationTools();
23+
24+
await Promise.all([
25+
tools.detachRunnerSessionsForShutdown(),
26+
tools.finalizeRunnerSessionsForShutdown(),
27+
]);
28+
29+
expect(detachIosSimulatorRunnerSessionsForShutdown).toHaveBeenCalledTimes(1);
30+
expect(stopAllIosRunnerSessions).toHaveBeenCalledTimes(1);
31+
});

src/platform-runtime-android-application-tools.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,36 @@ import { AppError } from '@agent-device/kernel/errors';
88
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
99
import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts';
1010

11+
/**
12+
* One memoized loader per lazily imported module, and the only way the ports below reach one.
13+
* See the same loaders in `platform-runtime-apple-application-tools.ts` for why a port never
14+
* opens its own `import(...)` (#2314).
15+
*/
16+
let openTargetModule: Promise<typeof import('./platform-runtime-open-target.ts')> | undefined;
17+
const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts'));
18+
19+
let runtimeHintsModule: Promise<typeof import('./platform-runtime-runtime-hints.ts')> | undefined;
20+
const loadRuntimeHints = () =>
21+
(runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts'));
22+
1123
/** Lazy Android tools; the Android package owns lifecycle sequencing and durable IME policy. */
1224
export function createAndroidApplicationTools(): AndroidApplicationTools {
1325
return Object.freeze({
1426
resolveOpenTarget: async (device, input) => await resolveAndroidOpenTarget(device, input),
1527
inferOpenedAppBundleId: async (device, target, currentAppBundleId) => {
16-
const { inferAndroidPackageAfterOpen } = await import('./platform-runtime-open-target.ts');
28+
const { inferAndroidPackageAfterOpen } = await loadOpenTarget();
1729
return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId);
1830
},
1931
resetFramePerfStats: async (device, appBundleId) => {
2032
const { resetAndroidFramePerfStats } = await loadAndroidMechanics();
2133
await resetAndroidFramePerfStats(device, appBundleId);
2234
},
2335
applyRuntimeHints: async (device, input) => {
24-
const { applyRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts');
36+
const { applyRuntimeHintValues } = await loadRuntimeHints();
2537
await applyRuntimeHintValues({ device, ...input });
2638
},
2739
clearRuntimeHints: async (device, input) => {
28-
const { clearRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts');
40+
const { clearRuntimeHintValues } = await loadRuntimeHints();
2941
await clearRuntimeHintValues({ device, ...input });
3042
},
3143
activateTestIme: async (device, input) => {
@@ -85,7 +97,7 @@ async function resolveAndroidOpenTarget(
8597
input: OpenTargetResolutionInput,
8698
): Promise<OpenTargetResolution> {
8799
const { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } =
88-
await import('./platform-runtime-open-target.ts');
100+
await loadOpenTarget();
89101
return {
90102
appBundleId: await resolveSessionAppBundleIdForTarget(
91103
device,
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1+
/**
2+
* The Android mechanics module, resolved exactly once per process.
3+
*
4+
* Memoized for the same reason as the loaders in
5+
* `platform-runtime-apple-application-tools.ts`: concurrent callers must share one resolution,
6+
* or a `vi.mock` factory still in flight lets the second caller reach the unmocked module (#2314).
7+
*/
8+
let mechanicsModule: Promise<typeof import('@agent-device/platform-android/mechanics')> | undefined;
9+
110
export async function loadAndroidMechanics() {
2-
await import('./platform-runtime-android-adb-host.ts');
3-
return await import('@agent-device/platform-android/mechanics');
11+
mechanicsModule ??= import('./platform-runtime-android-adb-host.ts').then(
12+
async () => await import('@agent-device/platform-android/mechanics'),
13+
);
14+
return await mechanicsModule;
415
}

src/platform-runtime-apple-application-tools.ts

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,49 @@ import type {
99
import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
1010
import { AppError } from '@agent-device/kernel/errors';
1111

12+
/**
13+
* One memoized loader per lazily imported module, and the only way the ports below reach one.
14+
*
15+
* A port must never open its own `import(...)`: the open path deliberately leaves the runner
16+
* prewarm unawaited, so two ports routinely resolve the same specifier at the same time. In
17+
* production those duplicates are equivalent — the loader caches — but under Vitest they are
18+
* not: while a `vi.mock` factory is still awaiting `importOriginal()`, a second dynamic import
19+
* of that id resolves to the UNMOCKED module. A unit test's mocked runner then escapes into the
20+
* real local XCTest runner, whose stale-process cleanup `pkill`s xcodebuild on the developer's
21+
* host and fails whichever test happens to be running when it lands (#2314). Resolving each
22+
* specifier exactly once removes the second resolution that the escape needs.
23+
*/
24+
let runnerOperationsModule:
25+
| Promise<typeof import('@agent-device/platform-apple/runner/operations')>
26+
| undefined;
27+
const loadRunnerOperations = () =>
28+
(runnerOperationsModule ??= import('@agent-device/platform-apple/runner/operations'));
29+
30+
let runtimeHintsModule: Promise<typeof import('./platform-runtime-runtime-hints.ts')> | undefined;
31+
const loadRuntimeHints = () =>
32+
(runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts'));
33+
34+
let openTargetModule: Promise<typeof import('./platform-runtime-open-target.ts')> | undefined;
35+
const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts'));
36+
37+
let appResolutionModule:
38+
| Promise<typeof import('@agent-device/platform-apple/app-resolution')>
39+
| undefined;
40+
const loadAppResolution = () =>
41+
(appResolutionModule ??= import('@agent-device/platform-apple/app-resolution'));
42+
43+
let macOsModule: Promise<typeof import('@agent-device/platform-apple/macos')> | undefined;
44+
const loadMacOs = () => (macOsModule ??= import('@agent-device/platform-apple/macos'));
45+
46+
let retryModule: Promise<typeof import('@agent-device/host-kit/retry')> | undefined;
47+
const loadRetry = () => (retryModule ??= import('@agent-device/host-kit/retry'));
48+
1249
/** Lazy Apple tools; the Apple package owns lifecycle sequencing around these primitives. */
1350
export function createAppleApplicationTools(): AppleApplicationTools {
1451
return Object.freeze({
1552
resolveOpenTarget: async (device, input) => await resolveAppleOpenTarget(device, input),
1653
prewarmRunnerCache: async (device, execution, signal) => {
17-
const { prewarmAppleRunnerCache } =
18-
await import('@agent-device/platform-apple/runner/operations');
54+
const { prewarmAppleRunnerCache } = await loadRunnerOperations();
1955
await prewarmAppleRunnerCache(device, appleRunnerOptions(execution, signal));
2056
},
2157
prewarmRunnerSession: async (
@@ -25,32 +61,29 @@ export function createAppleApplicationTools(): AppleApplicationTools {
2561
propagateError,
2662
options?: AppleRunnerSessionPrewarmOptions,
2763
) => {
28-
const { prewarmIosRunnerSession } =
29-
await import('@agent-device/platform-apple/runner/operations');
64+
const { prewarmIosRunnerSession } = await loadRunnerOperations();
3065
await prewarmIosRunnerSession(device, {
3166
...appleRunnerOptions(execution, signal),
3267
propagateError,
3368
...options,
3469
});
3570
},
3671
notifyRunnerAppRelaunched: async (device, execution, signal) => {
37-
const { notifyIosRunnerAppRelaunched } =
38-
await import('@agent-device/platform-apple/runner/operations');
72+
const { notifyIosRunnerAppRelaunched } = await loadRunnerOperations();
3973
await notifyIosRunnerAppRelaunched(device, appleRunnerOptions(execution, signal));
4074
},
4175
stopRunnerSession: async (deviceId) => {
42-
const { stopIosRunnerSession } =
43-
await import('@agent-device/platform-apple/runner/operations');
76+
const { stopIosRunnerSession } = await loadRunnerOperations();
4477
await stopIosRunnerSession(deviceId);
4578
},
4679
scheduleRunnerIdleStop: (deviceId) => {
47-
void import('@agent-device/platform-apple/runner/operations').then(
48-
({ scheduleIosRunnerIdleStop }) => scheduleIosRunnerIdleStop(deviceId),
80+
void loadRunnerOperations().then(({ scheduleIosRunnerIdleStop }) =>
81+
scheduleIosRunnerIdleStop(deviceId),
4982
);
5083
},
5184
prepareRunner: async (device, input, signal) => {
52-
const { Deadline } = await import('@agent-device/host-kit/retry');
53-
const { prepareIosRunner } = await import('@agent-device/platform-apple/runner/operations');
85+
const { Deadline } = await loadRetry();
86+
const { prepareIosRunner } = await loadRunnerOperations();
5487
const startedAtMs = Date.now();
5588
return await prepareIosRunner(device, {
5689
...appleRunnerOptions(input.execution, signal),
@@ -62,22 +95,20 @@ export function createAppleApplicationTools(): AppleApplicationTools {
6295
});
6396
},
6497
applyRuntimeHints: async (device, input) => {
65-
const { applyRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts');
98+
const { applyRuntimeHintValues } = await loadRuntimeHints();
6699
await applyRuntimeHintValues({ device, ...input });
67100
},
68101
clearRuntimeHints: async (device, input) => {
69-
const { clearRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts');
102+
const { clearRuntimeHintValues } = await loadRuntimeHints();
70103
await clearRuntimeHintValues({ device, ...input });
71104
},
72105
dismissCloseAlerts: async (device, input) => await dismissMacOsCloseAlerts(device, input),
73106
detachRunnerSessionsForShutdown: async () => {
74-
const { detachIosSimulatorRunnerSessionsForShutdown } =
75-
await import('@agent-device/platform-apple/runner/operations');
107+
const { detachIosSimulatorRunnerSessionsForShutdown } = await loadRunnerOperations();
76108
await detachIosSimulatorRunnerSessionsForShutdown();
77109
},
78110
finalizeRunnerSessionsForShutdown: async () => {
79-
const { stopAllIosRunnerSessions } =
80-
await import('@agent-device/platform-apple/runner/operations');
111+
const { stopAllIosRunnerSessions } = await loadRunnerOperations();
81112
await stopAllIosRunnerSessions();
82113
},
83114
});
@@ -100,7 +131,7 @@ async function resolveAppleOpenTarget(
100131
);
101132
}
102133
const macOsSurface = await resolveMacOsSurface(device, input.surface);
103-
const { resolveSessionAppBundleIdForTarget } = await import('./platform-runtime-open-target.ts');
134+
const { resolveSessionAppBundleIdForTarget } = await loadOpenTarget();
104135
return {
105136
appBundleId:
106137
macOsSurface.appBundleId ??
@@ -118,8 +149,7 @@ async function resolveAppleForegroundTarget(
118149
device: DeviceInfo,
119150
): Promise<OpenTargetResolution | undefined> {
120151
if (!isIosFamily(device) || device.kind !== 'simulator') return undefined;
121-
const { detectSoleRunningIosSimulatorApp } =
122-
await import('@agent-device/platform-apple/app-resolution');
152+
const { detectSoleRunningIosSimulatorApp } = await loadAppResolution();
123153
const app = await detectSoleRunningIosSimulatorApp(device);
124154
return app ? { appBundleId: app.bundleId, appName: app.name } : undefined;
125155
}
@@ -131,7 +161,7 @@ async function resolveMacOsSurface(
131161
if (!isMacOs(device) || surface === 'app' || surface === 'desktop' || surface === 'menubar') {
132162
return {};
133163
}
134-
const { resolveFrontmostMacOsApp } = await import('@agent-device/platform-apple/macos');
164+
const { resolveFrontmostMacOsApp } = await loadMacOs();
135165
const frontmost = await resolveFrontmostMacOsApp();
136166
return { appBundleId: frontmost.bundleId, appName: frontmost.appName };
137167
}
@@ -155,7 +185,7 @@ async function dismissMacOsCloseAlerts(
155185
input: CloseApplicationFinalizationInput,
156186
): Promise<void> {
157187
if (!isMacOs(device)) return;
158-
const { runMacOsAlertAction } = await import('@agent-device/platform-apple/macos');
188+
const { runMacOsAlertAction } = await loadMacOs();
159189
const dismissOptions =
160190
input.surface === 'frontmost-app'
161191
? { surface: 'frontmost-app' as const }

0 commit comments

Comments
 (0)