diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 77fcaccb7..e15c9e01b 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts'; import type { CommandFlags } from './core/dispatch.ts'; import { runCmdDetached } from './utils/exec.ts'; import { findProjectRoot, readVersion } from './utils/version.ts'; +import { stopProcessForTakeover } from './utils/process-identity.ts'; export type DaemonRequest = { token: string; @@ -19,12 +20,20 @@ export type DaemonResponse = | { ok: true; data?: Record } | { ok: false; error: { code: string; message: string; details?: Record } }; -type DaemonInfo = { port: number; token: string; pid: number; version?: string }; +type DaemonInfo = { + port: number; + token: string; + pid: number; + version?: string; + processStartTime?: string; +}; const baseDir = path.join(os.homedir(), '.agent-device'); const infoPath = path.join(baseDir, 'daemon.json'); -const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs(); +const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs(); const DAEMON_STARTUP_TIMEOUT_MS = 5000; +const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; +const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000; export async function sendToDaemon(req: Omit): Promise { const info = await ensureDaemon(); @@ -38,6 +47,7 @@ async function ensureDaemon(): Promise { const existingReachable = existing ? await canConnect(existing) : false; if (existing && existing.version === localVersion && existingReachable) return existing; if (existing && (existing.version !== localVersion || !existingReachable)) { + await stopDaemonProcessForTakeover(existing); removeDaemonInfo(); } @@ -56,12 +66,23 @@ async function ensureDaemon(): Promise { }); } +async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise { + await stopProcessForTakeover(info.pid, { + termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS, + killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS, + expectedStartTime: info.processStartTime, + }); +} + function readDaemonInfo(): DaemonInfo | null { if (!fs.existsSync(infoPath)) return null; try { const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo; if (!data.port || !data.token) return null; - return data; + return { + ...data, + pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0, + }; } catch { return null; } @@ -142,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise { + try { + fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw err; + } + }; + + if (tryWriteLock()) return true; + const existing = readLockInfo(); + if ( + existing?.pid + && existing.pid !== process.pid + && isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime) + ) { + return false; + } + // Best-effort stale-lock cleanup: another process may win the race between unlink and re-create. + // We rely on the subsequent write with `wx` to enforce single-writer semantics. + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + return tryWriteLock(); +} + +function releaseDaemonLock(): void { + const existing = readLockInfo(); + if (existing && existing.pid !== process.pid) return; + try { + if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); + } catch { + // ignore + } +} + function start(): void { + if (!acquireDaemonLock()) { + process.stderr.write('Daemon lock is held by another process; exiting.\n'); + process.exit(0); + return; + } + const server = net.createServer((socket) => { let buffer = ''; socket.setEncoding('utf8'); @@ -172,18 +252,28 @@ function start(): void { } }); + let shuttingDown = false; + const closeServer = async (): Promise => { + await new Promise((resolve) => { + try { + server.close(() => resolve()); + } catch { + resolve(); + } + }); + }; const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + await closeServer(); const sessionsToStop = sessionStore.toArray(); for (const session of sessionsToStop) { - if (session.device.platform === 'ios') { - await stopIosRunnerSession(session.device.id); - } sessionStore.writeSessionLog(session); } - server.close(() => { - removeInfo(); - process.exit(0); - }); + await stopAllIosRunnerSessions(); + removeInfo(); + releaseDaemonLock(); + process.exit(0); }; process.on('SIGINT', () => { diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 887dafccb..1646e695c 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -1,6 +1,6 @@ import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; +import { runIosRunnerCommand, stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; import { snapshotAndroid } from '../../platforms/android/index.ts'; import { attachRefs, @@ -78,45 +78,47 @@ export async function handleSnapshotCommands(params: { } snapshotScope = resolved; } - const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, { - ...contextFromFlags( - logPath, - { ...req.flags, snapshotScope }, - appBundleId, - session?.trace?.outPath, - ), - })) as { - nodes?: RawSnapshotNode[]; - truncated?: boolean; - backend?: 'ax' | 'xctest' | 'android'; - }; - const rawNodes = data?.nodes ?? []; - const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes)); - const snapshot: SnapshotState = { - nodes, - truncated: data?.truncated, - createdAt: Date.now(), - backend: data?.backend, - }; - const nextSession: SessionState = session - ? { ...session, snapshot } - : { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] }; - recordIfSession(sessionStore, nextSession, req, { - nodes: nodes.length, - truncated: data?.truncated ?? false, - }); - sessionStore.set(sessionName, nextSession); - return { - ok: true, - data: { + return await withSessionlessRunnerCleanup(session, device, async () => { + const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, { + ...contextFromFlags( + logPath, + { ...req.flags, snapshotScope }, + appBundleId, + session?.trace?.outPath, + ), + })) as { + nodes?: RawSnapshotNode[]; + truncated?: boolean; + backend?: 'ax' | 'xctest' | 'android'; + }; + const rawNodes = data?.nodes ?? []; + const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes)); + const snapshot: SnapshotState = { nodes, + truncated: data?.truncated, + createdAt: Date.now(), + backend: data?.backend, + }; + const nextSession: SessionState = session + ? { ...session, snapshot } + : { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] }; + recordIfSession(sessionStore, nextSession, req, { + nodes: nodes.length, truncated: data?.truncated ?? false, - appName: nextSession.appBundleId - ? (nextSession.appName ?? nextSession.appBundleId) - : undefined, - appBundleId: nextSession.appBundleId, - }, - }; + }); + sessionStore.set(sessionName, nextSession); + return { + ok: true, + data: { + nodes, + truncated: data?.truncated ?? false, + appName: nextSession.appBundleId + ? (nextSession.appName ?? nextSession.appBundleId) + : undefined, + appBundleId: nextSession.appBundleId, + }, + }; + }); } if (command === 'wait') { @@ -140,125 +142,127 @@ export async function handleSnapshotCommands(params: { error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' }, }; } - let text: string; - let timeoutMs: number | null; - if (parsed.kind === 'selector') { - const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - while (Date.now() - start < timeout) { - const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, { - ...contextFromFlags( - logPath, - { - ...req.flags, - snapshotInteractiveOnly: false, - snapshotCompact: false, - }, - session?.appBundleId, - session?.trace?.outPath, - ), - })) as { - nodes?: RawSnapshotNode[]; - truncated?: boolean; - backend?: 'ax' | 'xctest' | 'android'; - }; - const rawNodes = data?.nodes ?? []; - const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes)); - if (session) { - session.snapshot = { - nodes, - truncated: data?.truncated, - createdAt: Date.now(), - backend: data?.backend, + return await withSessionlessRunnerCleanup(session, device, async () => { + let text: string; + let timeoutMs: number | null; + if (parsed.kind === 'selector') { + const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const start = Date.now(); + while (Date.now() - start < timeout) { + const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, { + ...contextFromFlags( + logPath, + { + ...req.flags, + snapshotInteractiveOnly: false, + snapshotCompact: false, + }, + session?.appBundleId, + session?.trace?.outPath, + ), + })) as { + nodes?: RawSnapshotNode[]; + truncated?: boolean; + backend?: 'ax' | 'xctest' | 'android'; }; - sessionStore.set(sessionName, session); - } - const match = findSelectorChainMatch(nodes, parsed.selector, { platform: device.platform }); - if (match) { - recordIfSession(sessionStore, session, req, { - selector: match.selector.raw, - waitedMs: Date.now() - start, - }); - return { - ok: true, - data: { + const rawNodes = data?.nodes ?? []; + const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes)); + if (session) { + session.snapshot = { + nodes, + truncated: data?.truncated, + createdAt: Date.now(), + backend: data?.backend, + }; + sessionStore.set(sessionName, session); + } + const match = findSelectorChainMatch(nodes, parsed.selector, { platform: device.platform }); + if (match) { + recordIfSession(sessionStore, session, req, { selector: match.selector.raw, waitedMs: Date.now() - start, - }, - }; + }); + return { + ok: true, + data: { + selector: match.selector.raw, + waitedMs: Date.now() - start, + }, + }; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `wait timed out for selector: ${parsed.selectorExpression}`, - }, - }; - } else if (parsed.kind === 'ref') { - if (!session?.snapshot) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'Ref wait requires an existing snapshot in session.', - }, - }; - } - const ref = normalizeRef(parsed.rawRef); - if (!ref) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` }, - }; - } - const node = findNodeByRef(session.snapshot.nodes, ref); - const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined; - if (!resolved) { return { ok: false, error: { code: 'COMMAND_FAILED', - message: `Ref ${parsed.rawRef} not found or has no label`, + message: `wait timed out for selector: ${parsed.selectorExpression}`, }, }; - } - text = resolved; - timeoutMs = parsed.timeoutMs; - } else { - text = parsed.text; - timeoutMs = parsed.timeoutMs; - } - if (!text) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } }; - } - const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - while (Date.now() - start < timeout) { - if (device.platform === 'ios') { - const result = (await runIosRunnerCommand( - device, - { command: 'findText', text, appBundleId: session?.appBundleId }, - { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, - )) as { found?: boolean }; - if (result?.found) { - recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); - return { ok: true, data: { text, waitedMs: Date.now() - start } }; + } else if (parsed.kind === 'ref') { + if (!session?.snapshot) { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'Ref wait requires an existing snapshot in session.', + }, + }; + } + const ref = normalizeRef(parsed.rawRef); + if (!ref) { + return { + ok: false, + error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` }, + }; + } + const node = findNodeByRef(session.snapshot.nodes, ref); + const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined; + if (!resolved) { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: `Ref ${parsed.rawRef} not found or has no label`, + }, + }; } - } else if (device.platform === 'android') { - const androidResult = await snapshotAndroid(device, { scope: text }); - if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) { - recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); - return { ok: true, data: { text, waitedMs: Date.now() - start } }; + text = resolved; + timeoutMs = parsed.timeoutMs; + } else { + text = parsed.text; + timeoutMs = parsed.timeoutMs; + } + if (!text) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } }; + } + const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; + const start = Date.now(); + while (Date.now() - start < timeout) { + if (device.platform === 'ios') { + const result = (await runIosRunnerCommand( + device, + { command: 'findText', text, appBundleId: session?.appBundleId }, + { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, + )) as { found?: boolean }; + if (result?.found) { + recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); + return { ok: true, data: { text, waitedMs: Date.now() - start } }; + } + } else if (device.platform === 'android') { + const androidResult = await snapshotAndroid(device, { scope: text }); + if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) { + recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); + return { ok: true, data: { text, waitedMs: Date.now() - start } }; + } } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - return { - ok: false, - error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` }, - }; + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` }, + }; + }); } if (command === 'alert') { @@ -273,37 +277,39 @@ export async function handleSnapshotCommands(params: { }, }; } - if (action === 'wait') { - const timeout = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - while (Date.now() - start < timeout) { - try { - const data = await runIosRunnerCommand( - device, - { command: 'alert', action: 'get', appBundleId: session?.appBundleId }, - { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, - ); - recordIfSession(sessionStore, session, req, data as Record); - return { ok: true, data }; - } catch { - // keep waiting + return await withSessionlessRunnerCleanup(session, device, async () => { + if (action === 'wait') { + const timeout = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const data = await runIosRunnerCommand( + device, + { command: 'alert', action: 'get', appBundleId: session?.appBundleId }, + { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, + ); + recordIfSession(sessionStore, session, req, data as Record); + return { ok: true, data }; + } catch { + // keep waiting + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } }; } - return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } }; - } - const data = await runIosRunnerCommand( - device, - { - command: 'alert', - action: - action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get', - appBundleId: session?.appBundleId, - }, - { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, - ); - recordIfSession(sessionStore, session, req, data as Record); - return { ok: true, data }; + const data = await runIosRunnerCommand( + device, + { + command: 'alert', + action: + action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get', + appBundleId: session?.appBundleId, + }, + { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath }, + ); + recordIfSession(sessionStore, session, req, data as Record); + return { ok: true, data }; + }); } if (command === 'settings') { @@ -328,18 +334,20 @@ export async function handleSnapshotCommands(params: { }, }; } - const appBundleId = session?.appBundleId; - const data = await dispatchCommand( - device, - 'settings', - [setting, state, appBundleId ?? ''], - req.flags?.out, - { - ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), - }, - ); - recordIfSession(sessionStore, session, req, data ?? { setting, state }); - return { ok: true, data: data ?? { setting, state } }; + return await withSessionlessRunnerCleanup(session, device, async () => { + const appBundleId = session?.appBundleId; + const data = await dispatchCommand( + device, + 'settings', + [setting, state, appBundleId ?? ''], + req.flags?.out, + { + ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), + }, + ); + recordIfSession(sessionStore, session, req, data ?? { setting, state }); + return { ok: true, data: data ?? { setting, state } }; + }); } return null; @@ -398,6 +406,23 @@ async function resolveSessionDevice( return { session, device }; } +async function withSessionlessRunnerCleanup( + session: SessionState | undefined, + device: SessionState['device'], + task: () => Promise, +): Promise { + const shouldCleanupSessionlessIosRunner = !session && device.platform === 'ios'; + try { + return await task(); + } finally { + // Sessionless iOS commands intentionally stop the runner to avoid leaked xcodebuild processes. + // For multi-command flows, keep an active session via `open` so the runner can be reused. + if (shouldCleanupSessionlessIosRunner) { + await stopIosRunnerSession(device.id); + } + } +} + function recordIfSession( sessionStore: SessionStore, session: SessionState | undefined, diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index e314dfe79..90b850075 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -6,6 +6,8 @@ import { AppError } from '../../utils/errors.ts'; import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts'; import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import { withKeyedLock } from '../../utils/keyed-lock.ts'; +import { isProcessAlive } from '../../utils/process-identity.ts'; import net from 'node:net'; import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; @@ -55,6 +57,7 @@ export type RunnerSession = { }; const runnerSessions = new Map(); +const runnerSessionLocks = new Map>(); const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs( process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS, 120_000, @@ -128,13 +131,18 @@ export async function runIosRunnerCommand( return executeRunnerCommand(device, command, options); } +function withRunnerSessionLock(deviceId: string, task: () => Promise): Promise { + return withKeyedLock(runnerSessionLocks, deviceId, task); +} + async function executeRunnerCommand( device: DeviceInfo, command: RunnerCommand, options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {}, ): Promise> { + let session: RunnerSession | undefined; try { - const session = await ensureRunnerSession(device, options); + session = await ensureRunnerSession(device, options); const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS; return await executeRunnerCommandWithSession( device, @@ -150,8 +158,12 @@ async function executeRunnerCommand( typeof appErr.message === 'string' && appErr.message.includes('Runner did not accept connection') ) { - await stopIosRunnerSession(device.id); - const session = await ensureRunnerSession(device, options); + if (session) { + await stopRunnerSession(session); + } else { + await stopIosRunnerSession(device.id); + } + session = await ensureRunnerSession(device, options); const response = await waitForRunner( session.device, session.port, @@ -204,7 +216,27 @@ async function parseRunnerResponse( } export async function stopIosRunnerSession(deviceId: string): Promise { - const session = runnerSessions.get(deviceId); + await withRunnerSessionLock(deviceId, async () => { + await stopRunnerSessionInternal(deviceId); + }); +} + +export async function stopAllIosRunnerSessions(): Promise { + // Shutdown cleanup drains the sessions known at invocation time; daemon shutdown closes intake. + const pending = Array.from(runnerSessions.keys()); + await Promise.allSettled(pending.map(async (deviceId) => { + await stopIosRunnerSession(deviceId); + })); +} + +async function stopRunnerSession(session: RunnerSession): Promise { + await withRunnerSessionLock(session.deviceId, async () => { + await stopRunnerSessionInternal(session.deviceId, session); + }); +} + +async function stopRunnerSessionInternal(deviceId: string, sessionOverride?: RunnerSession): Promise { + const session = sessionOverride ?? runnerSessions.get(deviceId); if (!session) return; try { await waitForRunner(session.device, session.port, { @@ -227,7 +259,9 @@ export async function stopIosRunnerSession(deviceId: string): Promise { await killRunnerProcessTree(session.child.pid, 'SIGKILL'); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); - runnerSessions.delete(deviceId); + if (runnerSessions.get(deviceId) === session) { + runnerSessions.delete(deviceId); + } } async function ensureBooted(udid: string): Promise { @@ -241,58 +275,70 @@ async function ensureRunnerSession( device: DeviceInfo, options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, ): Promise { - const existing = runnerSessions.get(device.id); - if (existing) return existing; + return await withRunnerSessionLock(device.id, async () => { + const existing = runnerSessions.get(device.id); + if (existing) { + if (isRunnerProcessAlive(existing.child.pid)) { + return existing; + } + await stopRunnerSessionInternal(device.id, existing); + } - await ensureBootedIfNeeded(device); - const xctestrun = await ensureXctestrun(device, options); - const port = await getFreePort(); - const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv( - xctestrun, - { AGENT_DEVICE_RUNNER_PORT: String(port) }, - `session-${device.id}-${port}`, - ); - const { child, wait: testPromise } = runCmdBackground( - 'xcodebuild', - [ - 'test-without-building', - '-only-testing', - 'AgentDeviceRunnerUITests/RunnerTests/testCommand', - '-parallel-testing-enabled', - 'NO', - '-test-timeouts-enabled', - 'NO', - resolveRunnerMaxConcurrentDestinationsFlag(device), - '1', - '-xctestrun', + await ensureBootedIfNeeded(device); + const xctestrun = await ensureXctestrun(device, options); + const port = await getFreePort(); + const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv( + xctestrun, + { AGENT_DEVICE_RUNNER_PORT: String(port) }, + `session-${device.id}-${port}`, + ); + const { child, wait: testPromise } = runCmdBackground( + 'xcodebuild', + [ + 'test-without-building', + '-only-testing', + 'AgentDeviceRunnerUITests/RunnerTests/testCommand', + '-parallel-testing-enabled', + 'NO', + '-test-timeouts-enabled', + 'NO', + resolveRunnerMaxConcurrentDestinationsFlag(device), + '1', + '-xctestrun', + xctestrunPath, + '-destination', + resolveRunnerDestination(device), + ], + { + allowFailure: true, + env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, + }, + ); + child.stdout?.on('data', (chunk: string) => { + logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); + }); + child.stderr?.on('data', (chunk: string) => { + logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); + }); + + const session: RunnerSession = { + device, + deviceId: device.id, + port, xctestrunPath, - '-destination', - resolveRunnerDestination(device), - ], - { - allowFailure: true, - env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, - }, - ); - child.stdout?.on('data', (chunk: string) => { - logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); - }); - child.stderr?.on('data', (chunk: string) => { - logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); + jsonPath, + testPromise, + child, + ready: false, + }; + runnerSessions.set(device.id, session); + return session; }); +} - const session: RunnerSession = { - device, - deviceId: device.id, - port, - xctestrunPath, - jsonPath, - testPromise, - child, - ready: false, - }; - runnerSessions.set(device.id, session); - return session; +function isRunnerProcessAlive(pid: number | undefined): boolean { + if (!pid) return false; + return isProcessAlive(pid); } async function killRunnerProcessTree( diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts new file mode 100644 index 000000000..c5d2a6cdd --- /dev/null +++ b/src/utils/__tests__/daemon-client.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveDaemonRequestTimeoutMs } from '../../daemon-client.ts'; +import { + isProcessAlive, + readProcessCommand, + stopProcessForTakeover, + waitForProcessExit, +} from '../process-identity.ts'; + +test('resolveDaemonRequestTimeoutMs defaults to 180000', () => { + assert.equal(resolveDaemonRequestTimeoutMs(undefined), 180000); +}); + +test('resolveDaemonRequestTimeoutMs enforces minimum timeout', () => { + assert.equal(resolveDaemonRequestTimeoutMs('100'), 1000); + assert.equal(resolveDaemonRequestTimeoutMs('2500'), 2500); + assert.equal(resolveDaemonRequestTimeoutMs('invalid'), 180000); +}); + +test('stopDaemonProcessForTakeover terminates a matching daemon process', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-test-')); + const daemonDir = path.join(root, 'agent-device', 'dist', 'src'); + const daemonScriptPath = path.join(daemonDir, 'daemon.js'); + fs.mkdirSync(daemonDir, { recursive: true }); + fs.writeFileSync(daemonScriptPath, 'setInterval(() => {}, 1000);\n', 'utf8'); + const child = spawn(process.execPath, [daemonScriptPath], { + stdio: 'ignore', + }); + const pid = child.pid; + assert.ok(pid, 'spawned child should have a pid'); + + try { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (readProcessCommand(pid) === null) { + t.skip('process command inspection is unavailable in this environment'); + return; + } + assert.equal(isProcessAlive(pid), true); + await stopProcessForTakeover(pid, { + termTimeoutMs: 1_500, + killTimeoutMs: 1_500, + }); + const exited = await waitForProcessExit(pid, 1500); + assert.equal(exited, true); + } finally { + if (isProcessAlive(pid)) { + process.kill(pid, 'SIGKILL'); + } + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('stopDaemonProcessForTakeover does not terminate non-daemon process', async () => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + const pid = child.pid; + assert.ok(pid, 'spawned child should have a pid'); + + try { + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(isProcessAlive(pid), true); + await stopProcessForTakeover(pid, { + termTimeoutMs: 100, + killTimeoutMs: 100, + }); + assert.equal(isProcessAlive(pid), true); + } finally { + if (isProcessAlive(pid)) { + process.kill(pid, 'SIGKILL'); + } + } +}); diff --git a/src/utils/__tests__/keyed-lock.test.ts b/src/utils/__tests__/keyed-lock.test.ts new file mode 100644 index 000000000..11fc1e49a --- /dev/null +++ b/src/utils/__tests__/keyed-lock.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { withKeyedLock } from '../keyed-lock.ts'; + +test('withKeyedLock serializes work per key', async () => { + const locks = new Map>(); + const order: string[] = []; + let active = 0; + let maxActive = 0; + + await Promise.all([ + withKeyedLock(locks, 'device-a', async () => { + order.push('start-1'); + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + order.push('end-1'); + }), + withKeyedLock(locks, 'device-a', async () => { + order.push('start-2'); + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + order.push('end-2'); + }), + ]); + + assert.equal(maxActive, 1); + assert.deepEqual(order, ['start-1', 'end-1', 'start-2', 'end-2']); +}); + +test('withKeyedLock allows concurrent work across different keys', async () => { + const locks = new Map>(); + let active = 0; + let maxActive = 0; + + await Promise.all([ + withKeyedLock(locks, 'device-a', async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + }), + withKeyedLock(locks, 'device-b', async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + }), + ]); + + assert.equal(maxActive, 2); +}); diff --git a/src/utils/__tests__/process-identity.test.ts b/src/utils/__tests__/process-identity.test.ts new file mode 100644 index 000000000..169826eb5 --- /dev/null +++ b/src/utils/__tests__/process-identity.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + isAgentDeviceDaemonCommand, + isProcessAlive, + readProcessStartTime, + readProcessCommand, +} from '../process-identity.ts'; + +test('isProcessAlive returns false for invalid pid', () => { + assert.equal(isProcessAlive(-1), false); +}); + +test('readProcessStartTime returns value for current process', () => { + const startTime = readProcessStartTime(process.pid); + if (startTime === null) { + assert.equal(readProcessCommand(process.pid), null); + return; + } + assert.ok(startTime.length > 0); +}); + +test('isAgentDeviceDaemonCommand matches expected daemon command', () => { + assert.equal( + isAgentDeviceDaemonCommand('node /usr/local/lib/node_modules/agent-device/dist/src/daemon.js'), + true, + ); + assert.equal( + isAgentDeviceDaemonCommand('node --experimental-strip-types /tmp/agent-device/src/daemon.ts'), + true, + ); + assert.equal(isAgentDeviceDaemonCommand('node -e "setInterval(() => {}, 1000)"'), false); +}); diff --git a/src/utils/keyed-lock.ts b/src/utils/keyed-lock.ts new file mode 100644 index 000000000..b6cd8d434 --- /dev/null +++ b/src/utils/keyed-lock.ts @@ -0,0 +1,14 @@ +export async function withKeyedLock( + locks: Map>, + key: string, + task: () => Promise, +): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(task); + locks.set(key, current); + return current.finally(() => { + if (locks.get(key) === current) { + locks.delete(key); + } + }); +} diff --git a/src/utils/process-identity.ts b/src/utils/process-identity.ts new file mode 100644 index 000000000..7fd82a07c --- /dev/null +++ b/src/utils/process-identity.ts @@ -0,0 +1,100 @@ +import { runCmdSync } from './exec.ts'; + +const PS_TIMEOUT_MS = 1_000; +const DAEMON_COMMAND_PATTERNS = [ + /(^|[\/\s"'=])dist\/src\/daemon\.js($|[\s"'])/, + /(^|[\/\s"'=])src\/daemon\.ts($|[\s"'])/, +]; + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export function readProcessStartTime(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + const result = runCmdSync('ps', ['-p', String(pid), '-o', 'lstart='], { + allowFailure: true, + timeoutMs: PS_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return null; + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +export function readProcessCommand(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + const result = runCmdSync('ps', ['-p', String(pid), '-o', 'command='], { + allowFailure: true, + timeoutMs: PS_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return null; + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +export function isAgentDeviceDaemonCommand(command: string): boolean { + const normalized = command.toLowerCase().replaceAll('\\', '/'); + if (!normalized.includes('agent-device')) return false; + return DAEMON_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +export function isAgentDeviceDaemonProcess(pid: number, expectedStartTime?: string): boolean { + if (!isProcessAlive(pid)) return false; + if (expectedStartTime) { + const actualStartTime = readProcessStartTime(pid); + if (!actualStartTime || actualStartTime !== expectedStartTime) return false; + } + const command = readProcessCommand(pid); + if (!command) return false; + return isAgentDeviceDaemonCommand(command); +} + +function trySignalProcess(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return false; + throw err; + } +} + +export async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + if (!isProcessAlive(pid)) return true; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (!isProcessAlive(pid)) return true; + } + return !isProcessAlive(pid); +} + +export async function stopProcessForTakeover( + pid: number, + options: { + termTimeoutMs: number; + killTimeoutMs: number; + expectedStartTime?: string; + }, +): Promise { + if (!isAgentDeviceDaemonProcess(pid, options.expectedStartTime)) return; + if (!trySignalProcess(pid, 'SIGTERM')) return; + if (await waitForProcessExit(pid, options.termTimeoutMs)) return; + if (!trySignalProcess(pid, 'SIGKILL')) return; + await waitForProcessExit(pid, options.killTimeoutMs); +}