diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index 66f7ecbdc..d438a067a 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -1,15 +1,27 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; +const { mockFindBootableIosSimulator, mockListAppleDevices } = vi.hoisted(() => ({ + mockFindBootableIosSimulator: vi.fn(), + mockListAppleDevices: vi.fn(), +})); + vi.mock('../../platforms/ios/devices.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, findBootableIosSimulator: vi.fn() }; + return { + ...actual, + findBootableIosSimulator: mockFindBootableIosSimulator, + listAppleDevices: mockListAppleDevices, + }; }); -import { resolveIosDevice } from '../dispatch-resolve.ts'; +import { + resolveIosDevice, + resolveTargetDevice, + withResolveTargetDeviceCacheScope, +} from '../dispatch-resolve.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; -import { findBootableIosSimulator } from '../../platforms/ios/devices.ts'; const physical: DeviceInfo = { platform: 'ios', @@ -38,11 +50,10 @@ const bootedSimulator: DeviceInfo = { booted: true, }; -const mockFindBootableIosSimulator = vi.mocked(findBootableIosSimulator); - beforeEach(() => { mockFindBootableIosSimulator.mockReset(); mockFindBootableIosSimulator.mockResolvedValue(null); + mockListAppleDevices.mockReset(); }); // --- Physical device rejected in favour of simulator fallback --- @@ -112,3 +123,53 @@ test('resolveIosDevice returns simulator directly when present in device list', assert.equal(result.kind, 'simulator'); assert.equal(mockFindBootableIosSimulator.mock.calls.length, 0); }); + +test('resolveTargetDevice reuses request-scoped device resolution cache for identical selectors', async () => { + mockListAppleDevices.mockResolvedValue([bootedSimulator]); + + const [first, second] = await withResolveTargetDeviceCacheScope(async () => [ + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + ]); + + assert.equal(first.id, 'sim-2'); + assert.equal(second.id, 'sim-2'); + assert.equal(mockListAppleDevices.mock.calls.length, 1); +}); + +test('resolveTargetDevice request cache key separates device selectors', async () => { + mockListAppleDevices.mockResolvedValue([simulator, bootedSimulator]); + + await withResolveTargetDeviceCacheScope(async () => { + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 16' }); + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }); + }); + + assert.equal(mockListAppleDevices.mock.calls.length, 2); +}); + +test('resolveTargetDevice does not reuse cache across request scopes', async () => { + mockListAppleDevices.mockResolvedValue([bootedSimulator]); + + await withResolveTargetDeviceCacheScope( + async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + ); + await withResolveTargetDeviceCacheScope( + async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + ); + + assert.equal(mockListAppleDevices.mock.calls.length, 2); +}); + +test('resolveTargetDevice reuses cache across nested request scopes', async () => { + mockListAppleDevices.mockResolvedValue([bootedSimulator]); + + await withResolveTargetDeviceCacheScope(async () => { + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }); + await withResolveTargetDeviceCacheScope( + async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + ); + }); + + assert.equal(mockListAppleDevices.mock.calls.length, 1); +}); diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 461fc7dbf..206b09f38 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { AppError } from '../utils/errors.ts'; import { normalizePlatformSelector, @@ -28,6 +29,8 @@ type ResolveDeviceFlags = Pick< | 'androidDeviceAllowlist' >; +const resolveTargetDeviceCacheScope = new AsyncLocalStorage>(); + type AppleDeviceSelector = { platform?: Exclude; target?: DeviceTarget; @@ -98,9 +101,25 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { + const cached = readResolveTargetDeviceCache(cacheKey); + if (cached) { + diagnosticData.cacheHit = true; + return cached; + } const selector = { platform: normalizedPlatform, target: flags.target, @@ -117,20 +136,23 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise(task: () => Promise): Promise { + if (resolveTargetDeviceCacheScope.getStore()) return await task(); + return await resolveTargetDeviceCacheScope.run(new Map(), task); +} + +function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined { + const cache = resolveTargetDeviceCacheScope.getStore(); + const cached = cache?.get(cacheKey); + if (!cached) return undefined; + return { ...cached }; +} + +function cacheResolvedTargetDevice(cacheKey: string, device: DeviceInfo): DeviceInfo { + resolveTargetDeviceCacheScope.getStore()?.set(cacheKey, { ...device }); + return device; +} + +function buildResolveTargetDeviceCacheKey(params: { + flags: ResolveDeviceFlags; + normalizedPlatform?: PlatformSelector; + iosSimulatorSetPath?: string; + androidSerialAllowlist?: ReadonlySet; +}): string { + const { flags, normalizedPlatform, iosSimulatorSetPath, androidSerialAllowlist } = params; + return JSON.stringify({ + platform: normalizedPlatform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorSetPath, + androidSerialAllowlist: androidSerialAllowlist + ? Array.from(androidSerialAllowlist).sort() + : undefined, + }); +} diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 81d1fca9e..245261209 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -39,7 +39,7 @@ import { import { readNotificationPayload } from './dispatch-payload.ts'; import { parseDeviceRotation } from './device-rotation.ts'; -export { resolveTargetDevice } from './dispatch-resolve.ts'; +export { resolveTargetDevice, withResolveTargetDeviceCacheScope } from './dispatch-resolve.ts'; export { shouldUseIosTapSeries, shouldUseIosDragSeries }; export type BatchStep = { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 63f082bdb..e913d219b 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -1,6 +1,10 @@ import path from 'node:path'; import type { CommandFlags } from '../core/dispatch.ts'; -import { dispatchCommand, resolveTargetDevice } from '../core/dispatch.ts'; +import { + dispatchCommand, + resolveTargetDevice, + withResolveTargetDeviceCacheScope, +} from '../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; import type { @@ -574,117 +578,119 @@ export function createRequestHandler( } try { - const scopedReq = scopeRequestSession(req); - emitDiagnostic({ - level: 'info', - phase: 'request_start', - data: { - session: scopedReq.session, - command: scopedReq.command, - tenant: scopedReq.meta?.tenantId, - isolation: scopedReq.meta?.sessionIsolation, - }, - }); - - const command = scopedReq.command; - const leaseScope = resolveLeaseScope(scopedReq); - if ( - !leaseAdmissionExemptCommands.has(command) && - scopedReq.meta?.sessionIsolation === 'tenant' - ) { - leaseRegistry.assertLeaseAdmission({ - tenantId: leaseScope.tenantId, - runId: leaseScope.runId, - leaseId: leaseScope.leaseId, - backend: leaseScope.leaseBackend, + return await withResolveTargetDeviceCacheScope(async () => { + const scopedReq = scopeRequestSession(req); + emitDiagnostic({ + level: 'info', + phase: 'request_start', + data: { + session: scopedReq.session, + command: scopedReq.command, + tenant: scopedReq.meta?.tenantId, + isolation: scopedReq.meta?.sessionIsolation, + }, }); - } - - const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); - const executionLockKey = sessionExecutionExemptCommands.has(command) - ? null - : await resolveExecutionLockKey(scopedReq, sessionName, sessionStore); - - const executeSessionRequest = async (): Promise => { - throwIfRequestCanceled(scopedReq); - const existingSession = sessionStore.get(sessionName); - if (existingSession) { - refreshRecordingHealth(existingSession); - sessionStore.set(sessionName, existingSession); - } - const lockedReq = applyRequestLockPolicy(scopedReq, existingSession); - const finalize = (response: DaemonResponse): DaemonResponse => - finalizeDaemonResponse(lockedReq, response, trackDownloadableArtifact); + const command = scopedReq.command; + const leaseScope = resolveLeaseScope(scopedReq); if ( - existingSession?.recording?.invalidatedReason && - shouldBlockForInvalidRecording(command) + !leaseAdmissionExemptCommands.has(command) && + scopedReq.meta?.sessionIsolation === 'tenant' ) { - return finalize({ - ok: false, - error: { - code: 'COMMAND_FAILED', - message: existingSession.recording.invalidatedReason, - }, + leaseRegistry.assertLeaseAdmission({ + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + leaseId: leaseScope.leaseId, + backend: leaseScope.leaseBackend, }); } - if ( - existingSession && - !lockedReq.meta?.lockPolicy && - !selectorValidationExemptCommands.has(command) - ) { - assertSessionSelectorMatches(existingSession, lockedReq.flags); - } - // Phase 1: Try specialized handler chain - const handlerResponse = await runHandlerChain({ - req: lockedReq, - sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke: handleRequest, - contextFromFlags: (flags, appBundleId, traceLogPath) => - ({ - ...contextFromFlags(logPath, flags, appBundleId, traceLogPath), - surface: sessionStore.get(sessionName)?.surface, - }) satisfies DaemonCommandContext, - }); - if (handlerResponse) return finalize(handlerResponse); - - // Phase 2: Require active session for generic dispatch - const session = sessionStore.get(sessionName); - if (!session) { - return finalize({ - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: 'No active session. Run open first.', - }, + const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); + const executionLockKey = sessionExecutionExemptCommands.has(command) + ? null + : await resolveExecutionLockKey(scopedReq, sessionName, sessionStore); + + const executeSessionRequest = async (): Promise => { + throwIfRequestCanceled(scopedReq); + const existingSession = sessionStore.get(sessionName); + if (existingSession) { + refreshRecordingHealth(existingSession); + sessionStore.set(sessionName, existingSession); + } + const lockedReq = applyRequestLockPolicy(scopedReq, existingSession); + const finalize = (response: DaemonResponse): DaemonResponse => + finalizeDaemonResponse(lockedReq, response, trackDownloadableArtifact); + + if ( + existingSession?.recording?.invalidatedReason && + shouldBlockForInvalidRecording(command) + ) { + return finalize({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: existingSession.recording.invalidatedReason, + }, + }); + } + if ( + existingSession && + !lockedReq.meta?.lockPolicy && + !selectorValidationExemptCommands.has(command) + ) { + assertSessionSelectorMatches(existingSession, lockedReq.flags); + } + + // Phase 1: Try specialized handler chain + const handlerResponse = await runHandlerChain({ + req: lockedReq, + sessionName, + logPath, + sessionStore, + leaseRegistry, + invoke: handleRequest, + contextFromFlags: (flags, appBundleId, traceLogPath) => + ({ + ...contextFromFlags(logPath, flags, appBundleId, traceLogPath), + surface: sessionStore.get(sessionName)?.surface, + }) satisfies DaemonCommandContext, }); - } - - // Phase 3: Dispatch command directly to device - const dispatchResponse = await dispatchGenericCommand({ - req: lockedReq, - session, - sessionName, - logPath, - sessionStore, - }); - return finalize(dispatchResponse); - }; + if (handlerResponse) return finalize(handlerResponse); + + // Phase 2: Require active session for generic dispatch + const session = sessionStore.get(sessionName); + if (!session) { + return finalize({ + ok: false, + error: { + code: 'SESSION_NOT_FOUND', + message: 'No active session. Run open first.', + }, + }); + } + + // Phase 3: Dispatch command directly to device + const dispatchResponse = await dispatchGenericCommand({ + req: lockedReq, + session, + sessionName, + logPath, + sessionStore, + }); + return finalize(dispatchResponse); + }; - if (!executionLockKey) { + if (!executionLockKey) { + throwIfRequestCanceled(scopedReq); + return await executeSessionRequest(); + } throwIfRequestCanceled(scopedReq); - return await executeSessionRequest(); - } - throwIfRequestCanceled(scopedReq); - return await withKeyedLock( - sessionExecutionLocks, - executionLockKey, - executeSessionRequest, - ); + return await withKeyedLock( + sessionExecutionLocks, + executionLockKey, + executeSessionRequest, + ); + }); } catch (error) { emitDiagnostic({ level: 'error', diff --git a/src/platforms/ios/__tests__/runner-transport.test.ts b/src/platforms/ios/__tests__/runner-transport.test.ts index 371a0510d..32bd347cd 100644 --- a/src/platforms/ios/__tests__/runner-transport.test.ts +++ b/src/platforms/ios/__tests__/runner-transport.test.ts @@ -1,8 +1,23 @@ -import { test } from 'vitest'; +import fs from 'node:fs'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; -import { waitForRunner } from '../runner-transport.ts'; + +const { mockRunCmd } = vi.hoisted(() => ({ + mockRunCmd: vi.fn(), +})); + +vi.mock('../../../utils/exec.ts', async () => { + const actual = + await vi.importActual('../../../utils/exec.ts'); + return { + ...actual, + runCmd: mockRunCmd, + }; +}); + +import { clearDeviceTunnelIpCache, waitForRunner } from '../runner-transport.ts'; const iosSimulator: DeviceInfo = { platform: 'ios', @@ -12,6 +27,23 @@ const iosSimulator: DeviceInfo = { booted: true, }; +const iosDevice: DeviceInfo = { + platform: 'ios', + id: 'device-1', + name: 'iPhone', + kind: 'device', + booted: true, +}; + +beforeEach(() => { + clearDeviceTunnelIpCache(); + mockRunCmd.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + test('waitForRunner propagates request cancellation without fallback', async () => { const signal = AbortSignal.abort(); await assert.rejects( @@ -35,3 +67,86 @@ test('waitForRunner propagates request cancellation without fallback', async () }, ); }); + +test('waitForRunner reuses cached physical-device tunnel IP across commands', async () => { + stubSuccessfulFetch(); + mockRunCmd.mockImplementation(async (_cmd: string, args: string[]) => { + const jsonPath = args[args.indexOf('--json-output') + 1]; + fs.writeFileSync( + jsonPath, + JSON.stringify({ + info: { outcome: 'success' }, + result: { connectionProperties: { tunnelIPAddress: 'fd00::123' } }, + }), + ); + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + + assert.equal(mockRunCmd.mock.calls.length, 1); + const fetchCalls = vi.mocked(fetch).mock.calls; + assert.equal(fetchCalls.length, 2); + assert.equal(fetchCalls[0]?.[0], 'http://[fd00::123]:8100/command'); + assert.equal(fetchCalls[1]?.[0], 'http://[fd00::123]:8100/command'); +}); + +test('waitForRunner keeps tunnel IP lookup request-local when no tunnel IP is available', async () => { + stubSuccessfulFetch(); + mockRunCmd.mockImplementation(async () => ({ exitCode: 1, stdout: '', stderr: '' })); + + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + + assert.equal(mockRunCmd.mock.calls.length, 1); + assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://127.0.0.1:8100/command'); +}); + +test('waitForRunner invalidates cached tunnel IP when localhost fallback succeeds', async () => { + const tunnelIps = ['fd00::123', 'fd00::456']; + mockRunCmd.mockImplementation(async (_cmd: string, args: string[]) => { + const jsonPath = args[args.indexOf('--json-output') + 1]; + fs.writeFileSync( + jsonPath, + JSON.stringify({ + info: { outcome: 'success' }, + result: { connectionProperties: { tunnelIPAddress: tunnelIps.shift() } }, + }), + ); + return { exitCode: 0, stdout: '', stderr: '' }; + }); + let staleTunnelFailed = false; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url === 'http://[fd00::123]:8100/command' && staleTunnelFailed) { + throw new Error('stale tunnel'); + } + if (url === 'http://[fd00::123]:8100/command') { + staleTunnelFailed = true; + } + return new Response('{}'); + }), + ); + + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + + const fetchCalls = vi.mocked(fetch).mock.calls.map(([input]) => String(input)); + assert.equal(mockRunCmd.mock.calls.length, 2); + assert.deepEqual(fetchCalls, [ + 'http://[fd00::123]:8100/command', + 'http://[fd00::123]:8100/command', + 'http://127.0.0.1:8100/command', + 'http://[fd00::456]:8100/command', + ]); +}); + +function stubSuccessfulFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}')), + ); +} diff --git a/src/platforms/ios/runner-transport.ts b/src/platforms/ios/runner-transport.ts index 47aad2f00..4eba000de 100644 --- a/src/platforms/ios/runner-transport.ts +++ b/src/platforms/ios/runner-transport.ts @@ -53,12 +53,20 @@ const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs( 10_000, 500, ); +const RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS = 30_000; export const RUNNER_DESTINATION_TIMEOUT_SECONDS = resolveTimeoutSeconds( process.env.AGENT_DEVICE_RUNNER_DESTINATION_TIMEOUT_SECONDS, 20, 5, ); +type DeviceTunnelIpCacheEntry = { + ip: string; + expiresAt: number; +}; + +const deviceTunnelIpCache = new Map(); + export async function waitForRunner( device: DeviceInfo, port: number, @@ -69,14 +77,23 @@ export async function waitForRunner( signal?: AbortSignal, ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); - let deviceTunnelIp: string | undefined; - const getEndpoints = async (timeoutBudgetMs?: number) => { - if (device.kind === 'device' && deviceTunnelIp === undefined) { - deviceTunnelIp = (await resolveDeviceTunnelIp(device.id, timeoutBudgetMs)) ?? undefined; - } - return resolveRunnerCommandEndpoints(device, port, deviceTunnelIp ?? null); + let requestTunnelIp: string | null | undefined; + const getEndpoints = async (timeoutBudgetMs?: number, forceRefresh = false) => { + const tunnelIp = await getDeviceTunnelIpForRequest({ + device, + timeoutBudgetMs, + forceRefresh, + requestTunnelIp, + setRequestTunnelIp: (ip) => { + requestTunnelIp = ip; + }, + }); + return { + endpoints: resolveRunnerCommandEndpoints(device, port, tunnelIp.ip), + cached: tunnelIp.sharedCacheHit, + }; }; - let endpoints = await getEndpoints(deadline.remainingMs()); + let { endpoints } = await getEndpoints(deadline.remainingMs()); let lastError: unknown = null; const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); try { @@ -91,35 +108,45 @@ export async function waitForRunner( if (session && session.child.exitCode !== null && session.child.exitCode !== undefined) { throw await buildRunnerEarlyExitError({ session, port, logPath }); } + let usedCachedTunnelIp = false; if (device.kind === 'device') { - endpoints = await getEndpoints(attemptDeadline?.remainingMs()); + const resolved = await getEndpoints(attemptDeadline?.remainingMs()); + endpoints = resolved.endpoints; + usedCachedTunnelIp = resolved.cached; } - for (const endpoint of endpoints) { - try { - const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs; - if (remainingMs <= 0) { - throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { - port, - timeoutMs, - }); - } - const response = await fetchWithTimeout( - endpoint, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(command), - }, - Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs), - signal, - ); - return response; - } catch (err) { - if (signal?.aborted || isRequestCanceledError(err)) { - throw createRequestCanceledError(); - } + const cachedTunnelEndpoint = usedCachedTunnelIp ? endpoints[0] : null; + const response = await tryRunnerEndpoints(endpoints, { + command, + port, + timeoutMs, + signal, + attemptDeadline, + onError: (endpoint, err) => { lastError = err; - } + if (device.kind === 'device' && endpoint === cachedTunnelEndpoint) { + invalidateDeviceTunnelIpCache(device.id); + } + }, + }); + if (response) return response; + if (device.kind === 'device' && usedCachedTunnelIp) { + invalidateDeviceTunnelIpCache(device.id); + const refreshed = await getEndpoints(attemptDeadline?.remainingMs(), true); + endpoints = refreshed.endpoints; + const refreshedResponse = await tryRunnerEndpoints(endpoints, { + command, + port, + timeoutMs, + signal, + attemptDeadline, + onError: (_endpoint, err) => { + lastError = err; + }, + }); + if (refreshedResponse) return refreshedResponse; + } + if (signal?.aborted) { + throw createRequestCanceledError(); } throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', { port, @@ -161,11 +188,99 @@ export async function waitForRunner( throw buildRunnerConnectError({ port, endpoints, logPath, lastError }); } -async function resolveRunnerCommandEndpoints( +async function tryRunnerEndpoints( + endpoints: string[], + params: { + command: RunnerCommand; + port: number; + timeoutMs: number; + signal?: AbortSignal; + attemptDeadline?: Deadline; + onError: (endpoint: string, error: unknown) => void; + }, +): Promise { + const { command, port, timeoutMs, signal, attemptDeadline, onError } = params; + for (const endpoint of endpoints) { + try { + const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs; + if (remainingMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port, + timeoutMs, + }); + } + return await fetchWithTimeout( + endpoint, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + }, + Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs), + signal, + ); + } catch (err) { + if (signal?.aborted || isRequestCanceledError(err)) { + throw createRequestCanceledError(); + } + onError(endpoint, err); + } + } + return null; +} + +async function getDeviceTunnelIpForRequest(params: { + device: DeviceInfo; + timeoutBudgetMs?: number; + forceRefresh: boolean; + requestTunnelIp: string | null | undefined; + setRequestTunnelIp: (ip: string | null) => void; +}): Promise<{ ip: string | null; sharedCacheHit: boolean }> { + const { device, timeoutBudgetMs, forceRefresh, requestTunnelIp, setRequestTunnelIp } = params; + if (device.kind !== 'device') { + return { ip: null, sharedCacheHit: false }; + } + if (!forceRefresh) { + const cached = readDeviceTunnelIpCache(device.id); + if (cached) return { ip: cached, sharedCacheHit: true }; + if (requestTunnelIp !== undefined) return { ip: requestTunnelIp, sharedCacheHit: false }; + } + const ip = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs); + setRequestTunnelIp(ip); + if (ip) writeDeviceTunnelIpCache(device.id, ip); + return { ip, sharedCacheHit: false }; +} + +function readDeviceTunnelIpCache(deviceId: string): string | null { + const cached = deviceTunnelIpCache.get(deviceId); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + deviceTunnelIpCache.delete(deviceId); + return null; + } + return cached.ip; +} + +function writeDeviceTunnelIpCache(deviceId: string, ip: string): void { + deviceTunnelIpCache.set(deviceId, { + ip, + expiresAt: Date.now() + RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS, + }); +} + +function invalidateDeviceTunnelIpCache(deviceId: string): void { + deviceTunnelIpCache.delete(deviceId); +} + +export function clearDeviceTunnelIpCache(): void { + deviceTunnelIpCache.clear(); +} + +function resolveRunnerCommandEndpoints( device: DeviceInfo, port: number, tunnelIp: string | null, -): Promise { +): string[] { const endpoints = [`http://127.0.0.1:${port}/command`]; if (device.kind !== 'device') { return endpoints;