Skip to content

Commit ef508bc

Browse files
committed
fix(apple-runner): a redirect's best-effort give-back forgives only its own release
The swallow written last round was too wide. `release()` does two things: it restores the host's own `~/Library/Developer/XCTestDevices` from the backup, and it gives the device-set lock back. The second is the one whose failure a teardown can afford, because the claim is spent and a reclaim from this process now reads it as dead. The first is a fact about this machine — without it the symlink stays pointed at the agent-device set and every later `simctl` run sees the wrong devices — and it was going into the same `catch {}` that runner-session and runner-disposal already had. Both sites now call one handle method, `releaseBestEffort`, which drops only an AppError carrying `ownerReleaseUnverified` and rethrows anything else; `release` stays strict for the build path, where `withProcessLock` owns which of two failures gets reported. A test where the restore's `renameSync` answers EACCES pins the rethrow, and the mutation is the bare `catch {}` returning. The standalone best-effort helper is gone, so there is one place that decides what a teardown forgives.
1 parent ea1baef commit ef508bc

9 files changed

Lines changed: 104 additions & 40 deletions

‎packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import { test } from 'vitest';
4+
import { test, vi } from 'vitest';
55
import { AppError } from '@agent-device/kernel/errors';
66
import type { DeviceInfo } from '@agent-device/kernel/device';
77
import { mkdtempForTestSync } from './tmp-dir.ts';
88
import {
99
acquireXcodebuildSimulatorSetRedirect,
10-
releaseXcodebuildSimulatorSetRedirectBestEffort,
1110
withXcodebuildSimulatorSetRedirect,
1211
} from '../runner-device-set.ts';
1312

@@ -127,7 +126,48 @@ test('a redirect handed back after its task keeps quiet about a release it canno
127126
assert.notEqual(redirect, null);
128127
makeReleaseUnverifiable(paths);
129128

130-
await releaseXcodebuildSimulatorSetRedirectBestEffort(redirect);
129+
await redirect?.releaseBestEffort();
131130
assert.equal(fs.existsSync(paths.lockDirPath), true);
132131
});
133132
});
133+
134+
test('a redirect that could not restore the host device set reports it instead of swallowing it', async () => {
135+
await withTempDir('device-set-restore-failure-', async (root) => {
136+
const paths = makeRedirectPaths(root);
137+
fs.mkdirSync(paths.requestedSetPath, { recursive: true });
138+
// The host has a device set of its own, so giving the redirect back renames it out of the
139+
// backup. Without this the release has nothing to restore and no rename to attempt.
140+
fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true });
141+
fs.writeFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'the host owns this');
142+
const redirect = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), {
143+
lockDirPath: paths.lockDirPath,
144+
xctestDeviceSetPath: paths.xctestDeviceSetPath,
145+
});
146+
assert.notEqual(redirect, null);
147+
148+
// The restore of the host's own `XCTestDevices` is a rename back from the backup, and a
149+
// refusal there is a fact about this machine that no caller may lose.
150+
let attempted = false;
151+
const realRename = fs.renameSync;
152+
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((
153+
from: fs.PathLike,
154+
to: fs.PathLike,
155+
) => {
156+
if (String(to) === paths.xctestDeviceSetPath && !attempted) {
157+
attempted = true;
158+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
159+
}
160+
return realRename(from as string, to as string);
161+
}) as typeof fs.renameSync);
162+
163+
try {
164+
await assert.rejects(
165+
() => redirect!.releaseBestEffort(),
166+
(error: unknown) => (error as NodeJS.ErrnoException).code === 'EACCES',
167+
);
168+
assert.equal(attempted, true);
169+
} finally {
170+
renameSpy.mockRestore();
171+
}
172+
});
173+
});

‎packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ beforeEach(async () => {
157157
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived');
158158
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({
159159
release: mockRedirectRelease,
160+
releaseBestEffort: mockRedirectRelease,
160161
});
161162
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
162163
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });

‎packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ beforeEach(async () => {
138138
});
139139
mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 });
140140
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived');
141-
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease });
141+
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({
142+
release: mockRedirectRelease,
143+
releaseBestEffort: mockRedirectRelease,
144+
});
142145
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
143146
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
144147
mockIsProcessAlive.mockReturnValue(true);
@@ -189,7 +192,7 @@ test('a release that arrives while the speculative start is still in flight stop
189192
});
190193
mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => {
191194
await gate;
192-
return { release: mockRedirectRelease };
195+
return { release: mockRedirectRelease, releaseBestEffort: mockRedirectRelease };
193196
});
194197

195198
const starting = ensureRunnerSession(device, { speculative: true });
@@ -215,7 +218,7 @@ test('a release that waits out a demanded start leaves that runner alone', async
215218
});
216219
mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => {
217220
await gate;
218-
return { release: mockRedirectRelease };
221+
return { release: mockRedirectRelease, releaseBestEffort: mockRedirectRelease };
219222
});
220223

221224
const starting = ensureRunnerSession(device, {});

‎packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ beforeEach(async () => {
146146
});
147147
mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 });
148148
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived');
149-
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease });
149+
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({
150+
release: mockRedirectRelease,
151+
releaseBestEffort: mockRedirectRelease,
152+
});
150153
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
151154
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
152155
mockIsProcessAlive.mockReturnValue(true);

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ beforeEach(async () => {
192192
});
193193
mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 });
194194
mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived');
195-
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease });
195+
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({
196+
release: mockRedirectRelease,
197+
releaseBestEffort: mockRedirectRelease,
198+
});
196199
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
197200
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
198201
mockIsProcessAlive.mockReturnValue(true);
@@ -700,7 +703,10 @@ test('runner session emits XCTest startup progress only after a runner rebuild',
700703
xctestrunPath: '/tmp/session-runner.xctestrun',
701704
jsonPath: '/tmp/session-runner.json',
702705
});
703-
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease });
706+
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({
707+
release: mockRedirectRelease,
708+
releaseBestEffort: mockRedirectRelease,
709+
});
704710
mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242));
705711
mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 }));
706712

‎packages/platform-apple/src/runner/runner-device-set.ts‎

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100;
2020
const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000;
2121

2222
export type XcodebuildSimulatorSetRedirectHandle = {
23+
/** Reconciles the host's device set and gives the lock back, reporting whatever goes wrong. */
2324
release: () => Promise<void>;
25+
/**
26+
* Gives the redirect back for a caller whose own outcome is already decided — a launch that
27+
* failed, a teardown that ran — and so has nothing left to displace. Only a release that cannot
28+
* verify ownership is dropped: that claim is spent, and a later reclaim reads it as dead. A
29+
* failure to restore the host's own `XCTestDevices` is not that, and is not swallowed.
30+
*/
31+
releaseBestEffort: () => Promise<void>;
2432
};
2533

2634
type XcodebuildSimulatorSetRedirectOptions = {
@@ -62,21 +70,6 @@ export async function withXcodebuildSimulatorSetRedirect<Task>(
6270
return await withProcessLock({ acquire: async () => redirect.release, task });
6371
}
6472

65-
/**
66-
* Gives a redirect back from a site that outlives a single task — a launch that already failed, a
67-
* teardown that already ran — and so has nothing left to displace. A release that cannot verify
68-
* ownership leaves the lock standing for the stale-clear path, which is the smaller loss.
69-
*/
70-
export async function releaseXcodebuildSimulatorSetRedirectBestEffort(
71-
redirect: XcodebuildSimulatorSetRedirectHandle | null | undefined,
72-
): Promise<void> {
73-
try {
74-
await redirect?.release();
75-
} catch {
76-
// The lock stays where it is; nobody but the stale-clear path may take it from here.
77-
}
78-
}
79-
8073
export async function acquireXcodebuildSimulatorSetRedirect(
8174
device: DeviceInfo,
8275
options: XcodebuildSimulatorSetRedirectOptions = {},
@@ -143,24 +136,40 @@ export async function acquireXcodebuildSimulatorSetRedirect(
143136
}
144137

145138
let released = false;
139+
const release = async () => {
140+
if (released) {
141+
return;
142+
}
143+
released = true;
144+
try {
145+
reconcileXcodebuildSimulatorSetRedirect({
146+
xctestDeviceSetPath,
147+
backupPath,
148+
});
149+
} finally {
150+
await releaseLock();
151+
}
152+
};
146153
return {
147-
release: async () => {
148-
if (released) {
149-
return;
150-
}
151-
released = true;
154+
release,
155+
releaseBestEffort: async () => {
152156
try {
153-
reconcileXcodebuildSimulatorSetRedirect({
154-
xctestDeviceSetPath,
155-
backupPath,
156-
});
157-
} finally {
158-
await releaseLock();
157+
await release();
158+
} catch (error) {
159+
// The one failure a caller with nothing left to report may drop. The lock stands under a
160+
// claim that has since been spent, which the next reclaim from this process reads as dead,
161+
// and `releaseProcessLock` has already recorded it in the request log. Anything else — a
162+
// restore of the host's own device set that could not run — is a fact about this machine.
163+
if (!isOwnerReleaseUnverified(error)) throw error;
159164
}
160165
},
161166
};
162167
}
163168

169+
function isOwnerReleaseUnverified(error: unknown): boolean {
170+
return error instanceof AppError && error.details?.ownerReleaseUnverified === true;
171+
}
172+
164173
// fallow-ignore-next-line complexity
165174
function reconcileXcodebuildSimulatorSetRedirect(paths: {
166175
xctestDeviceSetPath: string;

‎packages/platform-apple/src/runner/runner-disposal.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
} from './host.ts';
1212
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
1313
import { cleanupTempFile } from './runner-io.ts';
14-
import { releaseXcodebuildSimulatorSetRedirectBestEffort } from './runner-device-set.ts';
1514
import { waitForRunner } from './runner-startup-transport.ts';
1615
import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts';
1716
import {
@@ -192,7 +191,7 @@ async function cleanupRunnerSessionResources(
192191
await settleOwnedRunnerDeviceState(session, options);
193192
cleanupTempFile(session.xctestrunPath);
194193
cleanupTempFile(session.jsonPath);
195-
await releaseXcodebuildSimulatorSetRedirectBestEffort(session.simulatorSetRedirect);
194+
await session.simulatorSetRedirect?.releaseBestEffort();
196195
}
197196

198197
/**

‎packages/platform-apple/src/runner/runner-session-types.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ export type RunnerSession = {
3939
startupTimings?: Record<string, number>;
4040
startupTimingsReported?: boolean;
4141
logicalLeaseContext?: RunnerLogicalLeaseContext;
42-
simulatorSetRedirect?: { release: () => Promise<void> };
42+
/** `XcodebuildSimulatorSetRedirectHandle`, seen through the two operations a session performs. */
43+
simulatorSetRedirect?: {
44+
release: () => Promise<void>;
45+
releaseBestEffort: () => Promise<void>;
46+
};
4347
lease?: RunnerLease;
4448
};
4549

‎packages/platform-apple/src/runner/runner-session.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { isIosFamily, isApplePlatform, type DeviceInfo } from '@agent-device/ker
1414
import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context';
1515
import type { AppleRunnerLifecycleOptions } from './runner-provider.ts';
1616
import { getFreePort } from './runner-io.ts';
17-
import { releaseXcodebuildSimulatorSetRedirectBestEffort } from './runner-device-set.ts';
1817
import { waitForRunner, RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts';
1918
import { sendRunnerCommandOnce } from './runner-transport.ts';
2019
import {
@@ -248,7 +247,7 @@ async function startRunnerSessionWithLease(
248247
}),
249248
);
250249
} catch (error) {
251-
await releaseXcodebuildSimulatorSetRedirectBestEffort(simulatorSetRedirect);
250+
await simulatorSetRedirect?.releaseBestEffort();
252251
throw error;
253252
}
254253
const sessionId = buildRunnerSessionId(device.id, port);

0 commit comments

Comments
 (0)