Skip to content

Commit 8c49d54

Browse files
authored
fix: clarify proxy runner ownership (#882)
* fix: clarify proxy runner ownership * fix: tighten proxy runner review follow-ups
1 parent 98c0b1d commit 8c49d54

9 files changed

Lines changed: 279 additions & 40 deletions

File tree

src/commands/management/prepare.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const prepareCliSchema = {
3232
usageOverride: 'prepare ios-runner --platform ios|macos [--timeout <ms>]',
3333
listUsageOverride: 'prepare',
3434
helpDescription:
35-
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
35+
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
3636
summary:
3737
'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation',
3838
positionalArgs: ['ios-runner'],

src/daemon-client-lifecycle.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ import {
3232
type DaemonInfo,
3333
type DaemonStartupCleanupResult,
3434
} from './daemon-client-metadata.ts';
35-
import { canConnect, readRemoteDaemonHealth } from './daemon-client-transport.ts';
35+
import {
36+
canConnect,
37+
DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE,
38+
DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE,
39+
readRemoteDaemonHealth,
40+
} from './daemon-client-transport.ts';
3641

3742
export type DaemonClientSettings = {
3843
paths: DaemonPaths;
@@ -166,7 +171,7 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise<
166171
const existing = readDaemonInfo(settings.paths.infoPath);
167172
if (!existing) return null;
168173

169-
const existingReachable = await canConnect(existing, settings.transportPreference);
174+
const existingReachable = await canConnectReusableDaemon(existing, settings.transportPreference);
170175
if (isReusableDaemonInfo(existing, existingReachable)) return existing;
171176

172177
emitDaemonTakeoverNotice(existing, existingReachable, settings.paths.baseDir);
@@ -175,6 +180,27 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise<
175180
return null;
176181
}
177182

183+
async function canConnectReusableDaemon(
184+
info: DaemonInfo,
185+
preference: DaemonTransportPreference,
186+
): Promise<boolean> {
187+
try {
188+
return await canConnect(info, preference);
189+
} catch (error) {
190+
if (isDaemonTransportUnavailableError(error)) return false;
191+
throw error;
192+
}
193+
}
194+
195+
function isDaemonTransportUnavailableError(error: unknown): boolean {
196+
return (
197+
error instanceof AppError &&
198+
error.code === 'COMMAND_FAILED' &&
199+
(error.message === DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE ||
200+
error.message === DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE)
201+
);
202+
}
203+
178204
function isReusableDaemonInfo(info: DaemonInfo, reachable: boolean): boolean {
179205
return (
180206
info.version === readVersion() &&

src/daemon-client-transport.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ type ResolvedDaemonTransport = 'socket' | 'http';
2222

2323
const LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS = 500;
2424
const REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000;
25+
export const DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon HTTP endpoint is unavailable';
26+
export const DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon socket endpoint is unavailable';
2527

2628
export type RemoteDaemonHealth = {
2729
reachable: boolean;
@@ -254,8 +256,8 @@ function requireDaemonTransport(
254256
throw new AppError(
255257
'COMMAND_FAILED',
256258
transport === 'http'
257-
? 'Daemon HTTP endpoint is unavailable'
258-
: 'Daemon socket endpoint is unavailable',
259+
? DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE
260+
: DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE,
259261
);
260262
}
261263

@@ -294,7 +296,7 @@ async function sendSocketRequest(
294296
timeoutMs: number | undefined,
295297
): Promise<DaemonResponse> {
296298
const port = info.port;
297-
if (!port) throw new AppError('COMMAND_FAILED', 'Daemon socket endpoint is unavailable');
299+
if (!port) throw new AppError('COMMAND_FAILED', DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE);
298300
return new Promise((resolve, reject) => {
299301
let requestWritten = false;
300302
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
@@ -360,7 +362,7 @@ async function sendHttpRequest(
360362
: info.httpPort
361363
? new URL(`http://127.0.0.1:${info.httpPort}/rpc`)
362364
: null;
363-
if (!rpcUrl) throw new AppError('COMMAND_FAILED', 'Daemon HTTP endpoint is unavailable');
365+
if (!rpcUrl) throw new AppError('COMMAND_FAILED', DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE);
364366
const rpcPayload = JSON.stringify(buildHttpRpcPayload(req, { includeTokenParam: !info.baseUrl }));
365367
const headers: Record<string, string | number> = {
366368
'content-type': 'application/json',

src/platforms/ios/__tests__/runner-session.test.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -662,25 +662,80 @@ test('runner session startup kills legacy ownerless xcodebuild before launching
662662

663663
test('runner session startup rejects live foreign runner lease', async () => {
664664
const device = { ...IOS_SIMULATOR, id: 'runner-session-busy-lease-sim' };
665+
const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR;
666+
process.env.AGENT_DEVICE_STATE_DIR = '/tmp/agent-device-current';
665667
writeRunnerLease(
666668
makeRunnerLease({
667669
deviceId: device.id,
668670
ownerToken: 'owner-foreign-live',
669671
ownerPid: process.pid,
670672
ownerStartTime: RUNNER_OWNER_START_TIME,
673+
ownerStateDir: '/tmp/agent-device-owner',
671674
}),
672675
);
673676

674-
await assert.rejects(
675-
() => ensureRunnerSession(device, {}),
676-
/already owned by another agent-device daemon/,
677-
);
677+
try {
678+
let thrown: unknown;
679+
await assert.rejects(async () => {
680+
try {
681+
await ensureRunnerSession(device, {});
682+
} catch (error) {
683+
thrown = error;
684+
throw error;
685+
}
686+
}, /already owned by another agent-device daemon/);
687+
688+
assert.equal(
689+
(thrown as { details?: Record<string, unknown> }).details?.ownerStateDir,
690+
'/tmp/agent-device-owner',
691+
);
692+
assert.match(
693+
String((thrown as { details?: Record<string, unknown> }).details?.hint),
694+
/Do not run prepare ios-runner/,
695+
);
696+
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
697+
assert.equal(
698+
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
699+
false,
700+
);
701+
} finally {
702+
if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
703+
else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir;
704+
}
705+
});
678706

679-
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
680-
assert.equal(
681-
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
682-
false,
707+
test('runner session startup reclaims live foreign runner lease from same state dir', async () => {
708+
const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' };
709+
const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR;
710+
const stateDir = '/tmp/agent-device-proxy-state';
711+
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
712+
writeRunnerLease(
713+
makeRunnerLease({
714+
deviceId: device.id,
715+
ownerToken: 'owner-foreign-same-state',
716+
ownerPid: process.pid,
717+
ownerStartTime: RUNNER_OWNER_START_TIME,
718+
ownerStateDir: stateDir,
719+
runnerPid: 4_321,
720+
}),
683721
);
722+
723+
try {
724+
const session = await ensureRunnerSession(device, {});
725+
726+
assert.equal(session.deviceId, device.id);
727+
assert.equal(mockRunCmdBackground.mock.calls.length, 1);
728+
assert.deepEqual(mockCleanupTempFile.mock.calls, [
729+
[`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.xctestrun`],
730+
[`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.json`],
731+
]);
732+
const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall);
733+
assert.ok(pkillCalls.length >= 2);
734+
assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-same-state/);
735+
} finally {
736+
if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
737+
else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir;
738+
}
684739
});
685740

686741
test('runner session startup reclaims dead foreign runner lease before launching', async () => {

src/platforms/ios/runner-lease.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type RunnerLease = {
2222
ownerToken: string;
2323
ownerPid: number;
2424
ownerStartTime: string | null;
25+
ownerStateDir?: string;
2526
sessionId: string;
2627
runnerPid: number | null;
2728
port: number;
@@ -61,6 +62,7 @@ export function buildRunnerLease(params: {
6162
ownerToken: RUNNER_OWNER_TOKEN,
6263
ownerPid: RUNNER_OWNER_PID,
6364
ownerStartTime: RUNNER_OWNER_START_TIME,
65+
ownerStateDir: readCurrentStateDir(),
6466
sessionId: params.sessionId,
6567
runnerPid: params.runnerPid ?? null,
6668
port: params.port,
@@ -115,22 +117,47 @@ export async function prepareRunnerLeaseForStartup(
115117
return;
116118
}
117119
if (state.type === 'busy') {
120+
if (isSameStateDirRunnerLease(state.lease)) {
121+
await cleanupLeasedRunnerProcesses(state.lease, 'same-state-dir', cleanup);
122+
return;
123+
}
118124
throw new AppError(
119125
'COMMAND_FAILED',
120126
`iOS runner for ${deviceId} is already owned by another agent-device daemon`,
121127
{
122128
deviceId,
123129
ownerPid: state.lease.ownerPid,
124130
ownerStartTime: state.lease.ownerStartTime,
131+
ownerStateDir: state.lease.ownerStateDir,
125132
ownerToken: state.lease.ownerToken,
126133
sessionId: state.lease.sessionId,
127-
hint: 'Use a different simulator/session, wait for the other run to finish, or stop the owning daemon before retrying.',
134+
hint: buildBusyRunnerLeaseHint(state.lease),
128135
},
129136
);
130137
}
131138
await cleanupLeasedRunnerProcesses(state.lease, state.type, cleanup);
132139
}
133140

141+
function isSameStateDirRunnerLease(lease: RunnerLease): boolean {
142+
// Same-state reclaim assumes callers sharing AGENT_DEVICE_STATE_DIR are the same logical daemon owner.
143+
const currentStateDir = readCurrentStateDir();
144+
if (!currentStateDir || !lease.ownerStateDir) return false;
145+
return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir);
146+
}
147+
148+
function readCurrentStateDir(): string | undefined {
149+
return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined;
150+
}
151+
152+
function buildBusyRunnerLeaseHint(lease: RunnerLease): string {
153+
const owner = `PID ${lease.ownerPid}`;
154+
const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : '';
155+
return [
156+
`The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`,
157+
'Do not run prepare ios-runner from another daemon/client to recover this; a live foreign runner lease cannot be released by the remote client.',
158+
].join(' ');
159+
}
160+
134161
export async function cleanupOwnedRunnerLease(
135162
deviceId: string,
136163
cleanup: RunnerLeaseCleanupAdapter,
@@ -240,6 +267,7 @@ function normalizeRunnerLease(value: unknown, deviceId: string): RunnerLease | n
240267
deviceId,
241268
...fields,
242269
ownerStartTime: readOptionalString(raw.ownerStartTime),
270+
ownerStateDir: readOptionalString(raw.ownerStateDir) ?? undefined,
243271
runnerPid: readPositiveInteger(raw.runnerPid),
244272
};
245273
}
@@ -286,15 +314,16 @@ function isRunnerLeaseOwnerAlive(lease: RunnerLease): boolean {
286314

287315
async function cleanupLeasedRunnerProcesses(
288316
lease: RunnerLease,
289-
reason: 'owned' | 'stale',
317+
reason: 'owned' | 'stale' | 'same-state-dir',
290318
cleanup: RunnerLeaseCleanupAdapter,
291319
): Promise<void> {
292320
emitDiagnostic({
293-
level: reason === 'stale' ? 'warn' : 'debug',
321+
level: reason === 'stale' || reason === 'same-state-dir' ? 'warn' : 'debug',
294322
phase: 'ios_runner_lease_cleanup',
295323
data: {
296324
deviceId: lease.deviceId,
297325
ownerPid: lease.ownerPid,
326+
ownerStateDir: lease.ownerStateDir,
298327
ownerToken: lease.ownerToken,
299328
runnerPid: lease.runnerPid,
300329
sessionId: lease.sessionId,

src/utils/__tests__/args.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,10 @@ test('usage includes only global flags in the top-level global flags section', (
10961096

10971097
test('usage includes agent workflows, config, environment, and examples footers', () => {
10981098
const usageText = usage();
1099+
assert.ok(
1100+
usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'),
1101+
'Agent workflows should appear before the command list for agents that only read the top of help.',
1102+
);
10991103
assert.match(usageText, /Agent Quickstart:/);
11001104
assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/);
11011105
assert.match(usageText, /Use selectors or refs as positional targets/);
@@ -1122,7 +1126,12 @@ test('usage includes agent workflows, config, environment, and examples footers'
11221126
assert.match(usageText, /verify the action with diff snapshot -i or snapshot --diff/);
11231127
assert.match(usageText, /Sparse or AX-unavailable snapshot/);
11241128
assert.match(usageText, /macOS context menus use click <ref> --button secondary/);
1125-
assert.match(usageText, /Remote workflow profiles use --remote-config/);
1129+
assert.match(usageText, /Direct proxy: Cloud\/Linux clients can use iOS simulators/);
1130+
assert.match(usageText, /A proxy URL\/token means direct proxy mode/);
1131+
assert.match(usageText, /Direct proxy sessions: choose one explicit --session/);
1132+
assert.match(usageText, /do not use connect, --remote-config, tenant, run, or lease flags/);
1133+
assert.match(usageText, /Cloud\/remote-config profiles are separate from direct proxy/);
1134+
assert.match(usageText, /Do not substitute --config/);
11261135
assert.match(usageText, /app-owned back uses back/);
11271136
assert.match(usageText, /Web browser sessions: read help web/);
11281137
assert.match(
@@ -1230,6 +1239,10 @@ test('usageForCommand documents prepare ios-runner', () => {
12301239
assert.match(help, /XCTest runner/);
12311240
assert.match(help, /separate daemon/);
12321241
assert.match(help, /clean:daemon after prepare/);
1242+
assert.match(
1243+
help,
1244+
/not a recovery step for "runner already owned by another agent-device daemon"/,
1245+
);
12331246
assert.match(help, /Runner build\/start output is written to the session runner\.log/);
12341247
});
12351248

@@ -1321,6 +1334,10 @@ test('usageForCommand resolves workflow help topic', () => {
13211334
assert.match(help, /metro prepare --kind expo/);
13221335
assert.match(help, /agent-device prepare ios-runner --platform ios --timeout 240000/);
13231336
assert.match(help, /prepare ios-runner builds\/reuses the XCTest runner/);
1337+
assert.match(
1338+
help,
1339+
/not a recovery step for "runner already owned by another agent-device daemon"/,
1340+
);
13241341
assert.match(help, /prepared runner does not keep a live lease/);
13251342
assert.match(help, /help react-devtools/);
13261343
assert.match(help, /help react-native/);
@@ -1443,7 +1460,11 @@ test('usageForCommand resolves remote help topic', () => {
14431460
const help = usageForCommand('remote');
14441461
if (help === null) throw new Error('Expected remote help text');
14451462
assert.match(help, /agent-device connect/);
1446-
assert.match(help, /without --remote-config/);
1463+
assert.match(help, /There are two different remote modes/);
1464+
assert.match(help, /Direct proxy: agent-device proxy exposes a Mac you control/);
1465+
assert.match(help, /A cloud\/Linux client can use iOS simulators through that proxied Mac/);
1466+
assert.match(help, /Use one explicit --session across open, snapshot, interactions, and close/);
1467+
assert.match(help, /Do not use connect, --remote-config, tenant, run, or lease flags/);
14471468
assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/);
14481469
assert.match(help, /disconnect --remote-config \.\/remote-config\.json/);
14491470
assert.match(help, /Script flow, per-command config/);
@@ -1453,11 +1474,15 @@ test('usageForCommand resolves remote help topic', () => {
14531474
help,
14541475
/--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token <token>/,
14551476
);
1477+
assert.match(help, /agent-device open Maps --session maps/);
1478+
assert.match(help, /agent-device snapshot -i --session maps/);
1479+
assert.match(help, /agent-device close --session maps/);
14561480
assert.match(help, /store daemonBaseUrl and daemonAuthToken in normal agent-device\.json/);
1457-
assert.match(help, /Keep platform selection on each command or workflow/);
1481+
assert.match(help, /keep the same explicit --session until close/);
1482+
assert.match(help, /do not run prepare ios-runner from the remote client/);
1483+
assert.match(help, /same-proxy-state stale runner leases are reclaimed/);
14581484
assert.match(help, /same --remote-config to every operational command/);
1459-
assert.match(help, /do not use agent-device auth for this direct proxy flow/);
1460-
assert.match(help, /Do not use --remote-config unless you are using the tenant\/run\/lease/);
1485+
assert.match(help, /do not use agent-device auth, connect, disconnect, --remote-config/);
14611486
assert.match(help, /Do not use --config as a remote profile flag/);
14621487
assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/);
14631488
});

0 commit comments

Comments
 (0)