Skip to content

Commit 657a6fc

Browse files
authored
fix(ios): restore a legacy XCTestDevices redirect before the daemon serves requests (#2980)
* refactor(ios-runner): restore a legacy XCTestDevices redirect before the daemon serves requests The restore now runs inside the configureForDaemonLock startup phase, before the servers open and before daemon.json is published. Its diagnostics are buffered with the other startup diagnostics and written to daemon.log after publication. The migration-specific lifecycle method and its daemon wrapper are deleted. A restore failure is reported as a diagnostic, not thrown. A simulator_set_destination_not_found error now names the Xcode version that this process's runner cache decision already read, and never probes the toolchain; without that value it says "(version unreadable)". Docs say that a symlinked XCTestDevices is not supported and is removed at daemon startup, and that the Xcode version can be unreadable. Tests pin exact failure messages per fixture and cover the real daemon startup path. Captured fixtures use placeholder home, checkout and temp paths. * refactor(ios-runner): one resource diagnostic type; the XCTestDevices restore loads without the runner client ResourceDiagnostic in host-kit/diagnostics replaces the durable-capture, app-log, daemon-startup and legacy-restore copies of the same shape. The legacy XCTestDevices restore moves to its own module behind the runner-owner entry that configureForDaemonLock already loads, so it no longer imports the runner client (~27 ms on darwin) before readiness. A simulator_set_destination_not_found error omits the Xcode clause when this process has not read the version, instead of calling it unreadable. A daemon-level test proves a restore the host refuses is logged and the daemon still starts.
1 parent dbf113a commit 657a6fc

25 files changed

Lines changed: 487 additions & 375 deletions

‎packages/capture-kit/src/capture-admission/audio-probe-resource-recovery.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import {
1111
} from '@agent-device/contracts/platform-runtime';
1212
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
1313
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
14-
import type {
15-
DurableCaptureRecoveryControl,
16-
DurableCaptureRecoveryDiagnostic,
17-
} from '../durable-capture/index.ts';
14+
import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts';
15+
import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics';
1816
import { audioProbeDurableResource } from './audio-probe-session-resource.ts';
1917
import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts';
2018

@@ -26,7 +24,7 @@ export function recoverAudioProbeResourceAfterDaemonLock(params: {
2624
gateway: DeviceRuntimeGateway<PlatformRuntimeOperations>;
2725
scope: PlatformRequestScope;
2826
perRecordDeadlineMs?: number;
29-
onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void;
27+
onDiagnostic?: (diagnostic: ResourceDiagnostic) => void;
3028
}) {
3129
return audioProbeDurableResource.recoverOne(
3230
{

‎packages/capture-kit/src/capture-admission/perf-capture-resource-recovery.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import {
1212
} from '@agent-device/contracts/platform-runtime';
1313
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
1414
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
15-
import type {
16-
DurableCaptureRecoveryControl,
17-
DurableCaptureRecoveryDiagnostic,
18-
} from '../durable-capture/index.ts';
15+
import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts';
16+
import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics';
1917
import { perfCaptureDurableResource } from './perf-capture-session-resource.ts';
2018
import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts';
2119

@@ -27,7 +25,7 @@ export function recoverPerfCaptureResourceAfterDaemonLock(params: {
2725
gateway: DeviceRuntimeGateway<PlatformRuntimeOperations>;
2826
scope: PlatformRequestScope;
2927
perRecordDeadlineMs?: number;
30-
onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void;
28+
onDiagnostic?: (diagnostic: ResourceDiagnostic) => void;
3129
}) {
3230
return perfCaptureDurableResource.recoverOne(
3331
{

‎packages/capture-kit/src/capture-admission/screen-recording-resource-recovery.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import type {
1111
ScreenRecordingLiveHandle,
1212
} from '@agent-device/contracts/screen-recording-runtime';
1313
import { screenRecordingRecoveryUse } from '@agent-device/contracts/screen-recording-runtime-plan';
14-
import type {
15-
DurableCaptureRecoveryControl,
16-
DurableCaptureRecoveryDiagnostic,
17-
} from '../durable-capture/index.ts';
14+
import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts';
15+
import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics';
1816
import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts';
1917
import { screenRecordingDurableResource } from './screen-recording-session-resource.ts';
2018

@@ -26,7 +24,7 @@ export function recoverScreenRecordingResourceAfterDaemonLock(params: {
2624
gateway: DeviceRuntimeGateway<PlatformRuntimeOperations>;
2725
scope: PlatformRequestScope;
2826
perRecordDeadlineMs?: number;
29-
onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void;
27+
onDiagnostic?: (diagnostic: ResourceDiagnostic) => void;
3028
}) {
3129
return screenRecordingDurableResource.recoverOne(
3230
{

‎packages/capture-kit/src/durable-capture/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export type {
1818
} from './definition.ts';
1919
export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts';
2020
export type {
21-
DurableCaptureRecoveryDiagnostic,
2221
DurableCaptureRecoveryOutcome,
2322
DurableCaptureRecoveryParams,
2423
DurableCaptureRecoverySummary,

‎packages/capture-kit/src/durable-capture/recovery.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
isConfirmedCleanup,
77
} from '@agent-device/contracts/durable-resource';
88
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
9-
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
9+
import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics';
1010
import {
1111
acquireDurableCaptureRecoveryAuthorityBeforeDeadline,
1212
DurableCaptureRecoveryDeadlineError,
@@ -28,12 +28,6 @@ export type DurableCaptureRecoverySummary = Readonly<{
2828

2929
export type DurableCaptureRecoveryOutcome = 'ignored' | 'recovered' | 'retained';
3030

31-
export type DurableCaptureRecoveryDiagnostic = Readonly<{
32-
phase: string;
33-
resourcePath: string;
34-
data: Readonly<Record<string, unknown>>;
35-
}>;
36-
3731
export type DurableCaptureRecoveryParams<K extends string, H extends LiveResourceHandle<C>, C> = {
3832
definition: DurableCaptureRecordDefinition<K, C>;
3933
sessionsDir: string;
@@ -48,7 +42,7 @@ export type DurableCaptureRecoveryParams<K extends string, H extends LiveResourc
4842
scope: PlatformRequestScope,
4943
): Promise<DurableCaptureRecoveryControl<K, H, C>>;
5044
perRecordDeadlineMs?: number;
51-
onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void;
45+
onDiagnostic?: (diagnostic: ResourceDiagnostic) => void;
5246
};
5347

5448
export async function recoverDurableCaptureResourcesAfterDaemonLock<

‎packages/host-kit/src/diagnostics.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
flushDiagnosticsToSessionFile,
77
getDiagnosticsMeta,
88
registerDiagnosticSensitiveValue,
9+
type ResourceDiagnostic,
910
updateDiagnosticsScope,
1011
withDiagnosticsScope,
1112
withDiagnosticTimer,

‎packages/host-kit/src/internal/diagnostics.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ export function countDiagnosticEventsByPhase(phases: readonly string[]): number
123123
return total;
124124
}
125125

126+
/** A warning about one on-disk resource, handed to a caller that records it once its log exists. */
127+
export type ResourceDiagnostic = Readonly<{
128+
phase: string;
129+
resourcePath: string;
130+
data: Readonly<Record<string, unknown>>;
131+
}>;
132+
126133
export type DiagnosticEventInput = {
127134
level?: DiagnosticLevel;
128135
phase: string;

‎packages/platform-apple/src/runner-operations-facade.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ export {
3030
writeRunnerCacheMetadataForArtifacts,
3131
} from './core/runner-client.ts';
3232
export { queryAppleRunnerSelector } from './core/runner-selector-query.ts';
33-
export { restoreLegacyXctestDeviceSetRedirect } from './runner/runner-device-set.ts';
3433

3534
export async function cleanupRunnerLeasesForOwner(
3635
owner: Parameters<(typeof import('./core/runner-client.ts'))['cleanupRunnerLeasesForOwner']>[0],

‎packages/platform-apple/src/runner-owner-facade.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,13 @@ export {
33
setRunnerLeaseOwnerStateDir,
44
type RunnerDeviceClaimAuthorityProbe,
55
} from './core/runner-owner-state.ts';
6+
7+
export async function restoreLegacyXctestDeviceSetRedirect(
8+
...args: Parameters<
9+
(typeof import('./runner/legacy-xctest-device-set.ts'))['restoreLegacyXctestDeviceSetRedirect']
10+
>
11+
): Promise<void> {
12+
const { restoreLegacyXctestDeviceSetRedirect: restore } =
13+
await import('./runner/legacy-xctest-device-set.ts');
14+
restore(...args);
15+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { afterEach, test, vi } from 'vitest';
5+
import { mkdtempForTestSync } from './tmp-dir.ts';
6+
import { restoreLegacyXctestDeviceSetRedirect } from '../legacy-xctest-device-set.ts';
7+
8+
afterEach(() => {
9+
vi.restoreAllMocks();
10+
});
11+
12+
type LegacyRedirect = {
13+
xctestDeviceSetPath: string;
14+
backupPath: string;
15+
scopedSetPath: string;
16+
};
17+
18+
function makeLegacyPaths(): LegacyRedirect {
19+
const root = mkdtempForTestSync('agent-device-legacy-xctest-set-');
20+
const developer = path.join(root, 'Library', 'Developer');
21+
fs.mkdirSync(developer, { recursive: true });
22+
const scopedSetPath = path.join(root, 'tenant-set');
23+
fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true });
24+
return {
25+
xctestDeviceSetPath: path.join(developer, 'XCTestDevices'),
26+
backupPath: path.join(developer, 'XCTestDevices.agent-device-backup'),
27+
scopedSetPath,
28+
};
29+
}
30+
31+
test('an older redirect is undone: the symlink goes and the host set comes back', () => {
32+
const paths = makeLegacyPaths();
33+
fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true });
34+
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');
35+
36+
const phases = restoreRecordingPhases(paths);
37+
38+
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false);
39+
assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')));
40+
assert.equal(fs.existsSync(paths.backupPath), false);
41+
// Unlinking never follows the link: the scoped set it pointed at keeps its devices.
42+
assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID')));
43+
assert.deepEqual(phases, [
44+
'ios_runner_legacy_xctest_device_set_link_removed',
45+
'ios_runner_legacy_xctest_device_set_backup_restored',
46+
]);
47+
});
48+
49+
test('a symlink an older redirect left without a backup is removed, whatever it points at', () => {
50+
for (const target of ['scoped', 'external'] as const) {
51+
const paths = makeLegacyPaths();
52+
const linkTarget =
53+
target === 'scoped'
54+
? paths.scopedSetPath
55+
: path.join(path.dirname(paths.scopedSetPath), 'external-volume');
56+
fs.mkdirSync(linkTarget, { recursive: true });
57+
fs.symlinkSync(linkTarget, paths.xctestDeviceSetPath, 'dir');
58+
const diagnostics: unknown[] = [];
59+
60+
restoreLegacyXctestDeviceSetRedirect(
61+
(diagnostic) => diagnostics.push(diagnostic),
62+
paths.xctestDeviceSetPath,
63+
);
64+
65+
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath, { throwIfNoEntry: false }), undefined);
66+
assert.ok(fs.existsSync(linkTarget), target);
67+
assert.deepEqual(diagnostics, [
68+
{
69+
phase: 'ios_runner_legacy_xctest_device_set_link_removed',
70+
resourcePath: paths.xctestDeviceSetPath,
71+
data: { linkTarget },
72+
},
73+
]);
74+
}
75+
});
76+
77+
test('a backup never replaces a host set that is already in place', () => {
78+
const paths = makeLegacyPaths();
79+
fs.mkdirSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'), { recursive: true });
80+
fs.mkdirSync(path.join(paths.backupPath, 'OLD-UDID'), { recursive: true });
81+
82+
restoreRecordingPhases(paths);
83+
84+
assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID')));
85+
assert.ok(fs.existsSync(path.join(paths.backupPath, 'OLD-UDID')));
86+
});
87+
88+
test('a host with no leftovers is left untouched and reports nothing', () => {
89+
const paths = makeLegacyPaths();
90+
91+
const phases = restoreRecordingPhases(paths);
92+
93+
assert.equal(fs.existsSync(paths.xctestDeviceSetPath), false);
94+
assert.equal(fs.existsSync(paths.backupPath), false);
95+
assert.deepEqual(phases, []);
96+
});
97+
98+
/** Runs the restore with the other daemon's whole restore landing just before this one's `call`. */
99+
function withOtherDaemonFinishingBefore(
100+
call: 'unlinkSync' | 'renameSync',
101+
paths: LegacyRedirect,
102+
): string[] {
103+
const original = fs[call] as (...args: unknown[]) => unknown;
104+
vi.spyOn(fs, call).mockImplementationOnce(((...args: unknown[]) => {
105+
restoreRecordingPhases(paths);
106+
return original.apply(fs, args);
107+
}) as never);
108+
return restoreRecordingPhases(paths);
109+
}
110+
111+
test('a daemon whose unlink or rename the other daemon already made finishes the restore', () => {
112+
for (const call of ['unlinkSync', 'renameSync'] as const) {
113+
const paths = makeLegacyPaths();
114+
fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true });
115+
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');
116+
117+
const phases = withOtherDaemonFinishingBefore(call, paths);
118+
119+
assert.equal(
120+
phases.includes('ios_runner_legacy_xctest_device_set_restore_failed'),
121+
false,
122+
call,
123+
);
124+
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false, call);
125+
assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')), call);
126+
assert.equal(fs.existsSync(paths.backupPath), false, call);
127+
assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID')), call);
128+
vi.restoreAllMocks();
129+
}
130+
});
131+
132+
test('an unlink that fails while the link is still there is reported, not thrown', () => {
133+
const paths = makeLegacyPaths();
134+
fs.mkdirSync(paths.backupPath);
135+
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');
136+
vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => {
137+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
138+
});
139+
const diagnostics: unknown[] = [];
140+
141+
restoreLegacyXctestDeviceSetRedirect(
142+
(diagnostic) => diagnostics.push(diagnostic),
143+
paths.xctestDeviceSetPath,
144+
);
145+
146+
assert.deepEqual(diagnostics, [
147+
{
148+
phase: 'ios_runner_legacy_xctest_device_set_restore_failed',
149+
resourcePath: paths.xctestDeviceSetPath,
150+
data: { error: 'EACCES: permission denied' },
151+
},
152+
]);
153+
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), true);
154+
assert.ok(fs.existsSync(paths.backupPath));
155+
});
156+
157+
function restoreRecordingPhases(paths: LegacyRedirect): string[] {
158+
const phases: string[] = [];
159+
restoreLegacyXctestDeviceSetRedirect(
160+
(diagnostic) => phases.push(diagnostic.phase),
161+
paths.xctestDeviceSetPath,
162+
);
163+
return phases;
164+
}

0 commit comments

Comments
 (0)