Skip to content

Commit 1ee5cf6

Browse files
committed
fix(apple-runner): fence prep spawns behind a start-owned admission (#3220)
A runner start builds, launches, and health-checks without holding the device session lock, so a teardown racing that window could kill the build and still let the start retry into a second concurrent `xcodebuild build-for-testing` on the same DerivedData directory. Every start now carries an admission token. Teardown of an in-flight start closes the token before killing prep processes, so later calls see an explicit retired verdict instead of retrying into the race; an open that only queued behind a settled fence is readmitted and proceeds normally. The prepare loop owns one token across its retry so the replacement build stays authorized, and a caller leaving on its own deadline marks the start retry-pending so teardown does not stop a build another owner still waits on. abortAll/stopAll fence all device starts for their duration instead of awaiting per-device session locks. The machinery lives in runner-artifact.ts beside the prep ledger it gates; no new static module edges, no host allowlist.
1 parent 37fa3e7 commit 1ee5cf6

13 files changed

Lines changed: 1708 additions & 241 deletions
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { afterEach, beforeEach, test, vi } from 'vitest';
5+
import { isRequestCanceledError } from '@agent-device/kernel/errors';
6+
import type { ExecResult } from '@agent-device/host-kit/command';
7+
import { appleRunnerTestHost } from '../test-host.ts';
8+
import {
9+
addRunnerStartWaiter,
10+
cancelRunnerStartWaiter,
11+
ensureXctestrunArtifact,
12+
fenceRunnerStartAdmissionsForTeardown,
13+
openRunnerStartAdmission,
14+
runnerStartAdmitsPreparation,
15+
} from '../runner-xctestrun.ts';
16+
import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts';
17+
import { IOS_SIMULATOR } from './device-fixtures.ts';
18+
import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts';
19+
import { mkdtempForTestSync } from './tmp-dir.ts';
20+
21+
// The preparation-spawn seam of #3220: admission is read immediately before `xcodebuild
22+
// build-for-testing` is created, so a start whose device went down never answers the kill with
23+
// a replacement build. The tests drive `ensureXctestrunArtifact` with a stand-in `xcodebuild`
24+
// so the gate is proven at the spawn itself, not at a mock above it.
25+
26+
const runCmdStreaming = vi.fn();
27+
let projectRoot: string;
28+
let derived: string;
29+
30+
beforeEach(() => {
31+
projectRoot = mkdtempForTestSync('agent-device-start-admission-root-');
32+
fs.mkdirSync(
33+
path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'),
34+
{ recursive: true },
35+
);
36+
derived = mkdtempForTestSync('agent-device-start-admission-derived-');
37+
process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derived;
38+
runCmdStreaming.mockReset();
39+
appleRunnerTestHost.update({
40+
runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult),
41+
runCmdStreaming,
42+
findProjectRoot: () => projectRoot,
43+
readVersion: () => '0.0.0-test',
44+
});
45+
});
46+
47+
afterEach(() => {
48+
delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
49+
});
50+
51+
/**
52+
* The cold-build retry of the incident: a teardown kills the first `build-for-testing` before it
53+
* ever takes the session lock, and the retired start re-enters to build again. The second spawn
54+
* is what made close wait out its timeout, and it is exactly what the pre-spawn gate refuses —
55+
* the child that would be the replacement never exists.
56+
*/
57+
test('a start whose device was torn down spawns no replacement build after its first was killed', async () => {
58+
const device = { ...IOS_SIMULATOR, id: 'runner-admission-retry-sim' };
59+
const admission = openRunnerStartAdmission(device.id);
60+
let killFirstBuild: () => void = () => {};
61+
const firstBuildKilled = new Promise<void>((resolve) => {
62+
killFirstBuild = resolve;
63+
});
64+
runCmdStreaming.mockImplementationOnce(() => {
65+
killFirstBuild();
66+
// What a killed build surfaces as at this seam: the exec layer rejects once the tree was
67+
// signaled, and the start's retry re-enters from here.
68+
return Promise.reject(new Error('Command was aborted'));
69+
});
70+
71+
const firstStart = ensureXctestrunArtifact(device, { startAdmission: admission }).catch(
72+
(error: unknown) => error,
73+
);
74+
await firstBuildKilled;
75+
// The teardown fences the device and lifts once it settles. The start's own verdict survives
76+
// that: the retry which re-enters with a closed admission is the replacement build #3220 is
77+
// about, so it stays refused.
78+
fenceRunnerStartAdmissionsForTeardown(device.id)();
79+
80+
const failure = await firstStart;
81+
assert.equal(runCmdStreaming.mock.calls.length, 1, 'the killed build was the only spawn');
82+
assert.ok(failure instanceof Error, 'the killed build failed its start');
83+
84+
// The health retry the incident measured: same start, same options, after the kill.
85+
const retry = await ensureXctestrunArtifact(device, {
86+
startAdmission: admission,
87+
}).catch((error: unknown) => error);
88+
89+
assert.ok(isRequestCanceledError(retry), 'the fenced start fails as a canceled start');
90+
assert.equal(runCmdStreaming.mock.calls.length, 1, 'no replacement build was spawned');
91+
});
92+
93+
/**
94+
* The nearest negative: the same retry on a device no teardown touched must still build. A
95+
* guard that refused preparation on any hint of a prior failure would pass the case above and
96+
* break every cold start.
97+
*/
98+
test('a start nobody retired still builds after a killed build', async () => {
99+
const device = { ...IOS_SIMULATOR, id: 'runner-admission-survivor-sim' };
100+
const admission = openRunnerStartAdmission(device.id);
101+
runCmdStreaming
102+
.mockResolvedValueOnce({ exitCode: 143, stdout: '', stderr: '' } satisfies ExecResult)
103+
.mockImplementationOnce(async () => {
104+
await seedBuiltRunner();
105+
return { exitCode: 0, stdout: '', stderr: '' } satisfies ExecResult;
106+
});
107+
108+
await assert.rejects(() => ensureXctestrunArtifact(device, { startAdmission: admission }));
109+
const rebuilt = await ensureXctestrunArtifact(device, { startAdmission: admission });
110+
111+
assert.equal(runCmdStreaming.mock.calls.length, 2, 'the retry built the artifact');
112+
assert.equal(rebuilt.artifact, 'rebuilt');
113+
});
114+
115+
/**
116+
* A device under a teardown admits preparation no start carried: the prewarm's own build. The
117+
* fence answers by device, so a build phase carrying no token is refused for the length of the
118+
* close that raised it (#3220).
119+
*/
120+
test('a fenced device admits a preparation that carries no start', async () => {
121+
const device = { ...IOS_SIMULATOR, id: 'runner-admission-prewarm-sim' };
122+
const settleFence = fenceRunnerStartAdmissionsForTeardown(device.id);
123+
runCmdStreaming.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' } satisfies ExecResult);
124+
125+
try {
126+
const failure = await ensureXctestrunArtifact(device, {}).catch((error: unknown) => error);
127+
assert.ok(isRequestCanceledError(failure));
128+
assert.equal(runCmdStreaming.mock.calls.length, 0, 'the prewarm build was never spawned');
129+
} finally {
130+
settleFence();
131+
}
132+
assert.equal(
133+
runnerStartAdmitsPreparation(device.id),
134+
true,
135+
'the fence was the close: once it settles, preparation is admitted again',
136+
);
137+
});
138+
139+
/**
140+
* The window #3193 left measured and open: a waiter cancels before the first prep child exists,
141+
* so a ledger sweep has nothing to stop and the build would simply start. The cancellation closes
142+
* admission, and the spawn that follows it is refused.
143+
*/
144+
test('a cancellation that arrives before the first prep spawn refuses the build that would follow', async () => {
145+
const device = { ...IOS_SIMULATOR, id: 'runner-admission-early-cancel-sim' };
146+
const admission = openRunnerStartAdmission(device.id);
147+
const waiter = new AbortController();
148+
addRunnerStartWaiter(admission, waiter.signal);
149+
runCmdStreaming.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' } satisfies ExecResult);
150+
151+
assert.equal(cancelRunnerStartWaiter(admission, waiter.signal), true);
152+
const failure = await ensureXctestrunArtifact(device, { startAdmission: admission }).catch(
153+
(error: unknown) => error,
154+
);
155+
156+
assert.ok(isRequestCanceledError(failure));
157+
assert.equal(runCmdStreaming.mock.calls.length, 0, 'the never-canceled build was never spawned');
158+
});
159+
160+
/**
161+
* The nearest negative of the waiter rule, taken at the spawn seam: while another waiter is
162+
* still interested, a cancellation preserves the work — a start that asks next still builds.
163+
*/
164+
test('a spawn still admitted by a remaining waiter builds', async () => {
165+
const device = { ...IOS_SIMULATOR, id: 'runner-admission-peer-waiter-sim' };
166+
const admission = openRunnerStartAdmission(device.id);
167+
addRunnerStartWaiter(admission, new AbortController().signal);
168+
const leaving = new AbortController();
169+
addRunnerStartWaiter(admission, leaving.signal);
170+
runCmdStreaming.mockImplementation(async () => {
171+
await seedBuiltRunner();
172+
return { exitCode: 0, stdout: '', stderr: '' } satisfies ExecResult;
173+
});
174+
175+
assert.equal(cancelRunnerStartWaiter(admission, leaving.signal), false);
176+
const built = await ensureXctestrunArtifact(device, { startAdmission: admission });
177+
178+
assert.equal(runCmdStreaming.mock.calls.length, 1, 'the surviving waiter kept the build alive');
179+
assert.equal(built.artifact, 'rebuilt');
180+
});
181+
182+
/** Stands in for a successful `xcodebuild build-for-testing`: the products land under SYMROOT. */
183+
async function seedBuiltRunner(): Promise<void> {
184+
const symroot = path.join(derived, 'Build', 'Products');
185+
await seedRunnerProductBundle(
186+
path.join(symroot, 'Debug-iphonesimulator', 'AgentDeviceRunner.app'),
187+
);
188+
fs.writeFileSync(
189+
path.join(
190+
symroot,
191+
'AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator27.0-arm64.xctestrun',
192+
),
193+
`<?xml version="1.0" encoding="UTF-8"?>
194+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
195+
<plist version="1.0">
196+
<dict>
197+
<key>ProjectRootHint</key>
198+
<string>${projectRoot}</string>
199+
<key>ProductPaths</key>
200+
<array>
201+
<string>__TESTROOT__/Debug-iphonesimulator/AgentDeviceRunner.app</string>
202+
</array>
203+
</dict>
204+
</plist>`,
205+
);
206+
}

‎packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,12 +1061,22 @@ function assertBadCacheRecoverySideEffects(
10611061
fixtures.restoredArtifact,
10621062
'Runner did not accept connection',
10631063
]);
1064-
assert.deepEqual(mockEnsureRunnerSession.mock.calls[1]?.[1], {
1064+
const firstCallOptions = mockEnsureRunnerSession.mock.calls[0]?.[1] as
1065+
| { startAdmission?: unknown }
1066+
| undefined;
1067+
const retryOptions = mockEnsureRunnerSession.mock.calls[1]?.[1] as
1068+
| Record<string, unknown>
1069+
| undefined;
1070+
assert.deepEqual(retryOptions, {
10651071
healthTimeoutMs: 90_000,
10661072
buildTimeoutMs: 300_000,
10671073
cleanStaleBundles: true,
10681074
forceRunnerXctestrunRebuild: true,
1075+
startAdmission: firstCallOptions?.startAdmission,
10691076
});
1077+
// Both attempts of the prepare loop share one admission token: the retry
1078+
// is the replacement build for the first attempt, not a newcomer.
1079+
assert.ok(firstCallOptions?.startAdmission);
10701080
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
10711081
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime');
10721082
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 90_000);

0 commit comments

Comments
 (0)