diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 69ae70ba9..f3f23a38b 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -379,7 +379,7 @@ "count": 1 } }, - "src/platforms/apple/core/runner/runner-transport.ts": { + "src/platforms/apple/core/runner/runner-startup-transport.ts": { "crap_high": { "count": 1 } diff --git a/src/platforms/apple/core/__tests__/runner-disposal.test.ts b/src/platforms/apple/core/__tests__/runner-disposal.test.ts index 2ad1ebaa5..2c1b91e3d 100644 --- a/src/platforms/apple/core/__tests__/runner-disposal.test.ts +++ b/src/platforms/apple/core/__tests__/runner-disposal.test.ts @@ -33,8 +33,8 @@ vi.mock('../../../../utils/host-process.ts', async (importOriginal) => { }; }); -vi.mock('../runner/runner-transport.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../runner/runner-io.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, cleanupTempFile: mockCleanupTempFile }; }); diff --git a/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts b/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts index b06efc1f1..16ee6dfe5 100644 --- a/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts +++ b/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts @@ -66,13 +66,21 @@ vi.mock('../tool-provider.ts', async () => { }; }); -vi.mock('../runner/runner-transport.ts', async () => { - const actual = await vi.importActual( - '../runner/runner-transport.ts', - ); +vi.mock('../runner/runner-io.ts', async () => { + const actual = + await vi.importActual('../runner/runner-io.ts'); return { ...actual, getFreePort: mockGetFreePort, + }; +}); + +vi.mock('../runner/runner-startup-transport.ts', async () => { + const actual = await vi.importActual( + '../runner/runner-startup-transport.ts', + ); + return { + ...actual, waitForRunner: mockWaitForRunner, }; }); diff --git a/src/platforms/apple/core/__tests__/runner-session.test.ts b/src/platforms/apple/core/__tests__/runner-session.test.ts index cf239550a..57ce978a5 100644 --- a/src/platforms/apple/core/__tests__/runner-session.test.ts +++ b/src/platforms/apple/core/__tests__/runner-session.test.ts @@ -89,16 +89,33 @@ vi.mock('../tool-provider.ts', async () => { }; }); +vi.mock('../runner/runner-io.ts', async () => { + const actual = + await vi.importActual('../runner/runner-io.ts'); + return { + ...actual, + cleanupTempFile: mockCleanupTempFile, + getFreePort: mockGetFreePort, + }; +}); + +vi.mock('../runner/runner-startup-transport.ts', async () => { + const actual = await vi.importActual( + '../runner/runner-startup-transport.ts', + ); + return { + ...actual, + waitForRunner: mockWaitForRunner, + }; +}); + vi.mock('../runner/runner-transport.ts', async () => { const actual = await vi.importActual( '../runner/runner-transport.ts', ); return { ...actual, - cleanupTempFile: mockCleanupTempFile, - getFreePort: mockGetFreePort, sendRunnerCommandOnce: mockSendRunnerCommandOnce, - waitForRunner: mockWaitForRunner, }; }); diff --git a/src/platforms/apple/core/__tests__/runner-startup-transport.test.ts b/src/platforms/apple/core/__tests__/runner-startup-transport.test.ts new file mode 100644 index 000000000..4d4720144 --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-startup-transport.test.ts @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import type { ExecBackgroundResult } from '../../../../utils/exec.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { RunnerSession } from '../runner/runner-session-types.ts'; +import { + iosDevice, + iosSimulator, + makeTunnelIpLookup, + makeTunnelIpLookupSequence, + stubSuccessfulFetch, + usbmuxDeviceUnattachedError, + xctestIosDevice, +} from './runner-transport.fixtures.ts'; + +const { mockRunCmd, mockUsbmuxPostCommand } = vi.hoisted(() => ({ + mockRunCmd: vi.fn(), + mockUsbmuxPostCommand: vi.fn(), +})); + +vi.mock('../../../../utils/exec.ts', async () => { + const actual = await vi.importActual( + '../../../../utils/exec.ts', + ); + return { + ...actual, + runCmd: mockRunCmd, + }; +}); + +vi.mock('../runner/runner-usbmux.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usbmuxRunnerTransport: { + postCommand: mockUsbmuxPostCommand, + }, + }; +}); + +import { clearDeviceTunnelIpCache } from '../runner/runner-command-route.ts'; +import { waitForRunner } from '../runner/runner-startup-transport.ts'; + +beforeEach(() => { + clearDeviceTunnelIpCache(); + mockRunCmd.mockReset(); + mockUsbmuxPostCommand.mockReset(); + mockUsbmuxPostCommand.mockResolvedValue(new Response('{}')); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +test('waitForRunner propagates request cancellation without fallback', async () => { + const signal = AbortSignal.abort(); + await assert.rejects( + () => + waitForRunner( + iosSimulator, + 8100, + { command: 'snapshot' }, + undefined, + 5_000, + undefined, + signal, + ), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + const appError = error as AppError; + assert.equal(appError.code, 'COMMAND_FAILED'); + assert.equal(appError.message, 'request canceled'); + assert.equal(appError.message.includes('Runner did not accept connection'), false); + return true; + }, + ); +}); + +test('waitForRunner reuses cached physical-device tunnel IP across commands', async () => { + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + stubSuccessfulFetch(); + mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123')); + + 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 () => { + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + 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 uses simulator fallback within the attempt for ready sessions', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('ECONNREFUSED'); + }), + ); + mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' }); + + const response = await waitForRunner( + iosSimulator, + 8100, + { command: 'uptime' }, + undefined, + 5_000, + makeReadyRunnerSession(), + ); + + assert.equal(await response.text(), '{"ok":true}'); + assert.equal(vi.mocked(fetch).mock.calls.length, 1); + assert.equal(mockRunCmd.mock.calls.length, 1); + assert.equal(mockRunCmd.mock.calls[0]?.[0], 'xcrun'); + assert.deepEqual(mockRunCmd.mock.calls[0]?.[1]?.slice(0, 5), [ + 'simctl', + 'spawn', + iosSimulator.id, + '/usr/bin/curl', + '-s', + ]); +}); + +test('waitForRunner invalidates cached tunnel IP when localhost fallback succeeds', async () => { + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + mockRunCmd.mockImplementation(makeTunnelIpLookupSequence(['fd00::123', 'fd00::456'])); + 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', + ]); +}); + +test('waitForRunner preserves xcodebuild diagnostics when the runner exits during the final probe', async () => { + const session: RunnerSession = { + sessionId: 'starting-device-session', + device: xctestIosDevice, + deviceId: xctestIosDevice.id, + port: 8100, + xctestrunPath: '/tmp/runner.xctestrun', + jsonPath: '/tmp/runner.json', + testPromise: Promise.resolve({ + exitCode: 65, + stdout: '', + stderr: + 'The application could not be launched because the Developer App Certificate is not trusted.', + }), + child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'], + ready: false, + }; + mockUsbmuxPostCommand.mockImplementation(async () => { + (session.child as { exitCode: number | null }).exitCode = 65; + throw new Error('ECONNREFUSED'); + }); + + await assert.rejects( + () => + waitForRunner(xctestIosDevice, 8100, { command: 'uptime' }, '/tmp/runner.log', 100, session), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.message, 'Runner did not accept connection (xcodebuild exited early)'); + assert.equal( + (appError.details?.xcodebuild as { exitCode?: number } | undefined)?.exitCode, + 65, + ); + assert.match( + String((appError.details?.xcodebuild as { stderr?: string } | undefined)?.stderr), + /Developer App Certificate is not trusted/, + ); + return true; + }, + ); + + assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); +}); + +test('waitForRunner reports the usbmux verdict for xctest devices without retrying', async () => { + // Regression: an XCTest device has no tunnel, so retrying cannot attach a + // cable. Before this was terminal, readiness preflight and read-only + // commands burned the whole connect budget and lost the recovery hint. + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + stubSuccessfulFetch(); + + await assert.rejects( + () => waitForRunner(xctestIosDevice, 8100, { command: 'snapshot' }, undefined, 5_000), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.code, 'DEVICE_NOT_FOUND'); + assert.match(String(appError.details?.hint), /Connect the device by cable/); + assert.equal(appError.message.includes('Runner did not accept connection'), false); + return true; + }, + ); + + assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); + assert.equal(vi.mocked(fetch).mock.calls.length, 0); + assert.equal(mockRunCmd.mock.calls.length, 0); +}); + +test('waitForRunner routes coredevice physical devices through usbmux first', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + + assert.equal(response.status, 200); + assert.equal(fetchMock.mock.calls.length, 0); + assert.equal(mockRunCmd.mock.calls.length, 0); + assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); +}); + +test('waitForRunner falls back to the tunnel route inside the same attempt', async () => { + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + stubSuccessfulFetch(); + mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123')); + + const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); + + assert.equal(response.status, 200); + // One usbmux probe, then the tunnel endpoint — no retry round trip in between. + assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); + assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://[fd00::123]:8100/command'); +}); + +function makeReadyRunnerSession(): RunnerSession { + return { + sessionId: 'ready-session', + device: iosSimulator, + deviceId: iosSimulator.id, + port: 8100, + xctestrunPath: '/tmp/runner.xctestrun', + jsonPath: '/tmp/runner.json', + testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), + child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'], + ready: true, + }; +} diff --git a/src/platforms/apple/core/__tests__/runner-transport.fixtures.ts b/src/platforms/apple/core/__tests__/runner-transport.fixtures.ts new file mode 100644 index 000000000..45fa6dc2e --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-transport.fixtures.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import { vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { IOS_DEVICE, IOS_SIMULATOR } from '../../../../__tests__/test-utils/index.ts'; + +export const iosDevice = IOS_DEVICE; +export const iosSimulator = IOS_SIMULATOR; +export const xctestIosDevice = { + ...IOS_DEVICE, + iosPhysicalDeviceBackend: 'xctest' as const, +}; + +export function usbmuxDeviceUnattachedError(): AppError { + return new AppError('DEVICE_NOT_FOUND', 'iOS device is not available through usbmux', { + deviceId: iosDevice.id, + usbmuxDeviceAttached: false, + hint: 'Connect the device by cable, trust this Mac, keep it unlocked, and retry.', + }); +} + +export function makeTunnelIpLookup(tunnelIp: string) { + return makeTunnelIpLookupSequence([tunnelIp]); +} + +export function makeTunnelIpLookupSequence(tunnelIps: string[]) { + const remainingTunnelIps = [...tunnelIps]; + return async (_cmd: string, args: string[]) => { + const jsonPath = args[args.indexOf('--json-output') + 1]!; + fs.writeFileSync( + jsonPath, + JSON.stringify({ + info: { outcome: 'success' }, + result: { connectionProperties: { tunnelIPAddress: remainingTunnelIps.shift() } }, + }), + ); + return { exitCode: 0, stdout: '', stderr: '' }; + }; +} + +export function stubSuccessfulFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}')), + ); +} diff --git a/src/platforms/apple/core/__tests__/runner-transport.test.ts b/src/platforms/apple/core/__tests__/runner-transport.test.ts index fbf36c100..4a2628642 100644 --- a/src/platforms/apple/core/__tests__/runner-transport.test.ts +++ b/src/platforms/apple/core/__tests__/runner-transport.test.ts @@ -1,10 +1,14 @@ -import fs from 'node:fs'; import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { ExecBackgroundResult } from '../../../../utils/exec.ts'; import { AppError } from '@agent-device/kernel/errors'; -import type { RunnerSession } from '../runner/runner-session-types.ts'; +import { + iosDevice, + iosSimulator, + makeTunnelIpLookup, + stubSuccessfulFetch, + usbmuxDeviceUnattachedError, + xctestIosDevice, +} from './runner-transport.fixtures.ts'; const { mockRunCmd, mockUsbmuxPostCommand } = vi.hoisted(() => ({ mockRunCmd: vi.fn(), @@ -32,28 +36,7 @@ vi.mock('../runner/runner-usbmux.ts', async (importOriginal) => { }); import { clearDeviceTunnelIpCache } from '../runner/runner-command-route.ts'; -import { sendRunnerCommandOnce, waitForRunner } from '../runner/runner-transport.ts'; - -const iosSimulator: DeviceInfo = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, -}; - -const iosDevice: DeviceInfo = { - platform: 'apple', - id: 'device-1', - name: 'iPhone', - kind: 'device', - booted: true, -}; - -const xctestIosDevice: DeviceInfo = { - ...iosDevice, - iosPhysicalDeviceBackend: 'xctest', -}; +import { sendRunnerCommandOnce } from '../runner/runner-transport.ts'; beforeEach(() => { clearDeviceTunnelIpCache(); @@ -67,140 +50,6 @@ afterEach(() => { vi.unstubAllEnvs(); }); -test('waitForRunner propagates request cancellation without fallback', async () => { - const signal = AbortSignal.abort(); - await assert.rejects( - () => - waitForRunner( - iosSimulator, - 8100, - { command: 'snapshot' }, - undefined, - 5_000, - undefined, - signal, - ), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - const appError = error as AppError; - assert.equal(appError.code, 'COMMAND_FAILED'); - assert.equal(appError.message, 'request canceled'); - assert.equal(appError.message.includes('Runner did not accept connection'), false); - return true; - }, - ); -}); - -test('waitForRunner reuses cached physical-device tunnel IP across commands', async () => { - stubUsbmuxDeviceUnattached(); - 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 () => { - stubUsbmuxDeviceUnattached(); - 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 uses simulator fallback within the attempt for ready sessions', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new Error('ECONNREFUSED'); - }), - ); - mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' }); - - const response = await waitForRunner( - iosSimulator, - 8100, - { command: 'uptime' }, - undefined, - 5_000, - makeReadyRunnerSession(), - ); - - assert.equal(await response.text(), '{"ok":true}'); - assert.equal(vi.mocked(fetch).mock.calls.length, 1); - assert.equal(mockRunCmd.mock.calls.length, 1); - assert.equal(mockRunCmd.mock.calls[0]?.[0], 'xcrun'); - assert.deepEqual(mockRunCmd.mock.calls[0]?.[1]?.slice(0, 5), [ - 'simctl', - 'spawn', - 'sim-1', - '/usr/bin/curl', - '-s', - ]); -}); - -test('waitForRunner invalidates cached tunnel IP when localhost fallback succeeds', async () => { - stubUsbmuxDeviceUnattached(); - 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', - ]); -}); - test('sendRunnerCommandOnce does not retry or simulator fallback after request failure', async () => { vi.stubGlobal( 'fetch', @@ -250,9 +99,9 @@ test('sendRunnerCommandOnce routes coredevice physical devices through usbmux fi }); test('sendRunnerCommandOnce falls back to the tunnel route when usbmux reports the device unattached', async () => { - stubUsbmuxDeviceUnattached(); + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); stubSuccessfulFetch(); - stubTunnelIpLookup('fd00::123'); + mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123')); const response = await sendRunnerCommandOnce(iosDevice, 8100, { command: 'uptime' }, 5_000); @@ -262,7 +111,7 @@ test('sendRunnerCommandOnce falls back to the tunnel route when usbmux reports t }); test('sendRunnerCommandOnce keeps the usbmux verdict for xctest devices that have no tunnel', async () => { - stubUsbmuxDeviceUnattached(); + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); stubSuccessfulFetch(); await assert.rejects( @@ -276,54 +125,6 @@ test('sendRunnerCommandOnce keeps the usbmux verdict for xctest devices that hav assert.equal(mockRunCmd.mock.calls.length, 0); }); -test('waitForRunner reports the usbmux verdict for xctest devices without retrying', async () => { - // Regression: an XCTest device has no tunnel, so retrying cannot attach a - // cable. Before this was terminal, readiness preflight and read-only - // commands burned the whole connect budget and lost the recovery hint. - stubUsbmuxDeviceUnattached(); - stubSuccessfulFetch(); - - await assert.rejects( - () => waitForRunner(xctestIosDevice, 8100, { command: 'snapshot' }, undefined, 5_000), - (error: unknown) => { - const appError = error as AppError; - assert.equal(appError.code, 'DEVICE_NOT_FOUND'); - assert.match(String(appError.details?.hint), /Connect the device by cable/); - assert.equal(appError.message.includes('Runner did not accept connection'), false); - return true; - }, - ); - - assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); - assert.equal(vi.mocked(fetch).mock.calls.length, 0); - assert.equal(mockRunCmd.mock.calls.length, 0); -}); - -test('waitForRunner routes coredevice physical devices through usbmux first', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); - - assert.equal(response.status, 200); - assert.equal(fetchMock.mock.calls.length, 0); - assert.equal(mockRunCmd.mock.calls.length, 0); - assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); -}); - -test('waitForRunner falls back to the tunnel route inside the same attempt', async () => { - stubUsbmuxDeviceUnattached(); - stubSuccessfulFetch(); - stubTunnelIpLookup('fd00::123'); - - const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000); - - assert.equal(response.status, 200); - // One usbmux probe, then the tunnel endpoint — no retry round trip in between. - assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1); - assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://[fd00::123]:8100/command'); -}); - test('falls back to the tunnel when usbmux loses the device mid-connect', async () => { // usbmuxd listed the device, then answered Connect with "no such device" // (result 2) because it went away in between. That must reach the same @@ -338,55 +139,10 @@ test('falls back to the tunnel when usbmux loses the device mid-connect', async }), ); stubSuccessfulFetch(); - stubTunnelIpLookup('fd00::123'); + mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123')); const response = await sendRunnerCommandOnce(iosDevice, 8100, { command: 'uptime' }, 5_000); assert.equal(response.status, 200); assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://[fd00::123]:8100/command'); }); - -function stubUsbmuxDeviceUnattached(): void { - mockUsbmuxPostCommand.mockRejectedValue( - new AppError('DEVICE_NOT_FOUND', 'iOS device is not available through usbmux', { - deviceId: iosDevice.id, - usbmuxDeviceAttached: false, - hint: 'Connect the device by cable, trust this Mac, keep it unlocked, and retry.', - }), - ); -} - -function stubTunnelIpLookup(tunnelIp: string): void { - 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: tunnelIp } }, - }), - ); - return { exitCode: 0, stdout: '', stderr: '' }; - }); -} - -function stubSuccessfulFetch(): void { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('{}')), - ); -} - -function makeReadyRunnerSession(): RunnerSession { - return { - sessionId: 'ready-session', - device: iosSimulator, - deviceId: iosSimulator.id, - port: 8100, - xctestrunPath: '/tmp/runner.xctestrun', - jsonPath: '/tmp/runner.json', - testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), - child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'], - ready: true, - }; -} diff --git a/src/platforms/apple/core/runner/runner-artifact.ts b/src/platforms/apple/core/runner/runner-artifact.ts index 0c27dc702..17ef0bf3a 100644 --- a/src/platforms/apple/core/runner/runner-artifact.ts +++ b/src/platforms/apple/core/runner/runner-artifact.ts @@ -10,7 +10,7 @@ import { isRequestCanceledError } from '../../../../request/cancel.ts'; import { emitRequestProgress } from '../../../../request/progress.ts'; import { findProjectRoot } from '../../../../utils/version.ts'; import { resolveRunnerBuildFailureHint } from './runner-contract.ts'; -import { logChunk } from './runner-transport.ts'; +import { logChunk } from './runner-io.ts'; import { acquireXcodebuildSimulatorSetRedirect } from './runner-device-set.ts'; import { acquireRunnerXctestrunCacheLock, diff --git a/src/platforms/apple/core/runner/runner-disposal.ts b/src/platforms/apple/core/runner/runner-disposal.ts index 36b35cbf0..01a31763c 100644 --- a/src/platforms/apple/core/runner/runner-disposal.ts +++ b/src/platforms/apple/core/runner/runner-disposal.ts @@ -6,7 +6,8 @@ import { signalPidsBestEffort, } from '../../../../utils/host-process.ts'; import type { ExecBackgroundResult } from '../../../../utils/exec.ts'; -import { cleanupTempFile, waitForRunner } from './runner-transport.ts'; +import { cleanupTempFile } from './runner-io.ts'; +import { waitForRunner } from './runner-startup-transport.ts'; import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts'; import { cleanupOwnedRunnerLease, diff --git a/src/platforms/apple/core/runner/runner-lifecycle.ts b/src/platforms/apple/core/runner/runner-lifecycle.ts index ed168884f..897caa716 100644 --- a/src/platforms/apple/core/runner/runner-lifecycle.ts +++ b/src/platforms/apple/core/runner/runner-lifecycle.ts @@ -2,7 +2,8 @@ import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; import { isRequestCanceledError } from '../../../../request/cancel.ts'; -import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; +import { RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts'; +import { RUNNER_COMMAND_TIMEOUT_MS } from './runner-transport.ts'; import { type RunnerSession, assertExpectedRunnerSession, diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index 6dc5c4dc5..632dcdced 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -16,14 +16,13 @@ import { buildSimctlArgsForDevice } from '../simctl.ts'; import { runAppleToolCommand, runXcrun } from '../tool-provider.ts'; import { resolveRunnerDestination } from '../apple-runner-platform.ts'; import { resolveRunnerMaxConcurrentDestinationsFlag } from './runner-cache-metadata.ts'; +import { getFreePort, logChunk } from './runner-io.ts'; import { waitForRunner, - sendRunnerCommandOnce, - getFreePort, - logChunk, RUNNER_STARTUP_TIMEOUT_MS, RUNNER_DESTINATION_TIMEOUT_SECONDS, -} from './runner-transport.ts'; +} from './runner-startup-transport.ts'; +import { sendRunnerCommandOnce } from './runner-transport.ts'; import { acquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact, diff --git a/src/platforms/apple/core/runner/runner-startup-transport.ts b/src/platforms/apple/core/runner/runner-startup-transport.ts new file mode 100644 index 000000000..93a05e9ec --- /dev/null +++ b/src/platforms/apple/core/runner/runner-startup-transport.ts @@ -0,0 +1,440 @@ +import { createRequestCanceledError, isRequestCanceledError } from '../../../../request/cancel.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { requireExecSuccess } from '../../../../utils/exec.ts'; +import { Deadline, retryWithPolicy } from '../../../../utils/retry.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { classifyBootFailure, bootFailureHint } from '../../../boot-diagnostics.ts'; +import { buildSimctlArgsForDevice } from '../simctl.ts'; +import { runXcrun } from '../tool-provider.ts'; +import { + createRunnerCommandRouteResolver, + invalidateDeviceTunnelIpCache, + type RunnerCommandRoute, +} from './runner-command-route.ts'; +import { + buildRunnerConnectError, + buildRunnerEarlyExitError, + isUsbmuxDeviceUnattachedError, + shouldRetryRunnerConnectError, + type RunnerCommand, +} from './runner-contract.ts'; +import type { RunnerSession } from './runner-session-types.ts'; +import { + canFallBackFromUsbmux, + fetchWithTimeout, + RUNNER_COMMAND_TIMEOUT_MS, +} from './runner-transport.ts'; +import { usbmuxRunnerTransport } from './runner-usbmux.ts'; + +export const RUNNER_STARTUP_TIMEOUT_MS = 45_000; +const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = 250; +const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = 300; +const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = 2_000; +const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = 20_000; +export const RUNNER_DESTINATION_TIMEOUT_SECONDS = 20; + +export async function waitForRunner( + device: DeviceInfo, + port: number, + command: RunnerCommand, + logPath?: string, + timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS, + session?: RunnerSession, + signal?: AbortSignal, +): Promise { + const deadline = Deadline.fromTimeoutMs(timeoutMs); + const { resolveRoute, markUsbmuxUnattached } = createRunnerCommandRouteResolver(device, port); + let route = await resolveRoute(deadline.remainingMs()); + let lastError: unknown = null; + const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); + try { + return await retryWithPolicy( + async ({ deadline: attemptDeadline }) => { + const response = await attemptRunnerConnection({ + device, + port, + command, + timeoutMs, + logPath, + session, + route, + resolveRoute, + markUsbmuxUnattached, + signal, + attemptDeadline, + setRoute: (nextRoute) => { + route = nextRoute; + }, + setLastError: (err) => { + lastError = err; + }, + }); + if (response) return response; + throw buildRunnerEndpointProbeError({ + port, + endpoints: route.endpoints, + lastError, + signal, + }); + }, + { + maxAttempts, + baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS, + maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS, + jitter: 0.2, + shouldRetry: shouldRetryRunnerConnectError, + }, + { deadline, phase: 'ios_runner_connect', signal }, + ); + } catch (error) { + if (signal?.aborted || isRequestCanceledError(error)) { + throw createRequestCanceledError(); + } + if (isUsbmuxDeviceUnattachedError(error)) throw error; + if (!lastError) { + lastError = error; + } + } + + if (signal?.aborted) { + throw createRequestCanceledError(); + } + + if (device.kind === 'simulator') { + const remainingMs = deadline.remainingMs(); + if (remainingMs <= 0) { + throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); + } + const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); + return new Response(simResponse.body, { status: simResponse.status }); + } + + if (session?.child.exitCode !== null && session?.child.exitCode !== undefined) { + throw await buildRunnerEarlyExitError({ session, port, logPath }); + } + throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); +} + +type RunnerRouteResolver = ReturnType['resolveRoute']; + +async function attemptRunnerConnection(params: { + device: DeviceInfo; + port: number; + command: RunnerCommand; + timeoutMs: number; + logPath?: string; + session?: RunnerSession; + route: RunnerCommandRoute; + resolveRoute: RunnerRouteResolver; + markUsbmuxUnattached: () => void; + signal?: AbortSignal; + attemptDeadline?: Deadline; + setRoute: (route: RunnerCommandRoute) => void; + setLastError: (error: unknown) => void; +}): Promise { + await ensureRunnerAttemptCanStart(params); + + const primary = await tryPrimaryRunnerRoute(params); + if (primary.response) return primary.response; + + const simulatorFallback = await tryReadySimulatorEndpoint(params); + if (simulatorFallback) return simulatorFallback; + + return await tryRefreshedDeviceTunnel(params, primary.usedCachedTunnelIp); +} + +async function ensureRunnerAttemptCanStart(params: { + port: number; + timeoutMs: number; + logPath?: string; + session?: RunnerSession; + attemptDeadline?: Deadline; +}): Promise { + if (params.attemptDeadline?.isExpired()) { + throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port: params.port, + timeoutMs: params.timeoutMs, + }); + } + if (params.session?.child.exitCode !== null && params.session?.child.exitCode !== undefined) { + throw await buildRunnerEarlyExitError({ + session: params.session, + port: params.port, + logPath: params.logPath, + }); + } +} + +async function tryPrimaryRunnerRoute(params: { + device: DeviceInfo; + port: number; + command: RunnerCommand; + timeoutMs: number; + route: RunnerCommandRoute; + resolveRoute: RunnerRouteResolver; + signal?: AbortSignal; + attemptDeadline?: Deadline; + setRoute: (route: RunnerCommandRoute) => void; + setLastError: (error: unknown) => void; + markUsbmuxUnattached: () => void; +}): Promise<{ response: Response | null; usedCachedTunnelIp: boolean }> { + let route = params.route; + let usedCachedTunnelIp = false; + if (params.device.kind === 'device') { + route = await params.resolveRoute(params.attemptDeadline?.remainingMs()); + usedCachedTunnelIp = route.cachedTunnelIp; + params.setRoute(route); + } + + const runRoute = async (current: RunnerCommandRoute) => { + // Derived per route: a usbmux-first attempt only learns its cached tunnel + // endpoint after falling back, and a stale one must still be invalidated. + const cachedTunnelEndpoint = current.cachedTunnelIp ? current.endpoints[0] : null; + return await tryRunnerRoute(params.device, current, { + command: params.command, + port: params.port, + timeoutMs: params.timeoutMs, + signal: params.signal, + attemptDeadline: params.attemptDeadline, + onUsbmuxUnattached: params.markUsbmuxUnattached, + onError: (endpoint, err) => { + params.setLastError(err); + if (params.device.kind === 'device' && endpoint === cachedTunnelEndpoint) { + invalidateDeviceTunnelIpCache(params.device.id); + } + }, + }); + }; + + const response = await runRoute(route); + if (response || route.kind !== 'usbmux') return { response, usedCachedTunnelIp }; + + // usbmux reported the device as unattached: resolve the CoreDevice tunnel + // route and try it within the same attempt instead of burning a retry. + const fallback = await params.resolveRoute(params.attemptDeadline?.remainingMs()); + if (fallback.kind === 'usbmux') return { response: null, usedCachedTunnelIp }; + params.setRoute(fallback); + return { response: await runRoute(fallback), usedCachedTunnelIp: fallback.cachedTunnelIp }; +} + +async function tryReadySimulatorEndpoint(params: { + device: DeviceInfo; + port: number; + command: RunnerCommand; + session?: RunnerSession; + signal?: AbortSignal; + attemptDeadline?: Deadline; + setLastError: (error: unknown) => void; +}): Promise { + if (params.device.kind !== 'simulator' || !params.session?.ready) return null; + return await tryRunnerSimulatorEndpoint(params.device, params.port, params.command, { + signal: params.signal, + attemptDeadline: params.attemptDeadline, + onError: params.setLastError, + }); +} + +async function tryRefreshedDeviceTunnel( + params: { + device: DeviceInfo; + port: number; + command: RunnerCommand; + timeoutMs: number; + resolveRoute: RunnerRouteResolver; + signal?: AbortSignal; + attemptDeadline?: Deadline; + setRoute: (route: RunnerCommandRoute) => void; + setLastError: (error: unknown) => void; + }, + usedCachedTunnelIp: boolean, +): Promise { + if (params.device.kind !== 'device' || !usedCachedTunnelIp) return null; + invalidateDeviceTunnelIpCache(params.device.id); + const refreshed = await params.resolveRoute(params.attemptDeadline?.remainingMs(), true); + params.setRoute(refreshed); + return await tryRunnerRoute(params.device, refreshed, { + command: params.command, + port: params.port, + timeoutMs: params.timeoutMs, + signal: params.signal, + attemptDeadline: params.attemptDeadline, + onError: (_endpoint, err) => { + params.setLastError(err); + }, + }); +} + +function buildRunnerEndpointProbeError(params: { + port: number; + endpoints: string[]; + lastError: unknown; + signal?: AbortSignal; +}): AppError { + if (params.signal?.aborted) { + throw createRequestCanceledError(); + } + return new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', { + port: params.port, + endpoints: params.endpoints, + lastError: params.lastError ? String(params.lastError) : undefined, + }); +} + +async function tryRunnerRoute( + device: DeviceInfo, + route: RunnerCommandRoute, + params: { + command: RunnerCommand; + port: number; + timeoutMs: number; + signal?: AbortSignal; + attemptDeadline?: Deadline; + onUsbmuxUnattached?: () => void; + onError: (endpoint: string, error: unknown) => void; + }, +): Promise { + if (route.kind === 'network') { + return await tryRunnerEndpoints(route.endpoints, params); + } + const endpoint = route.endpoints[0]; + try { + const remainingMs = params.attemptDeadline?.remainingMs() ?? params.timeoutMs; + if (remainingMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port: params.port, + timeoutMs: params.timeoutMs, + }); + } + return await usbmuxRunnerTransport.postCommand( + device.id, + params.port, + params.command, + Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs), + params.signal, + ); + } catch (error) { + if (params.signal?.aborted || isRequestCanceledError(error)) { + throw createRequestCanceledError(); + } + if (isUsbmuxDeviceUnattachedError(error)) { + if (!canFallBackFromUsbmux(device, error)) { + // No tunnel exists for this device, so retrying cannot attach a cable. + // Throw the typed verdict so its recovery hint survives instead of + // being replaced by a generic connect failure. + throw error; + } + params.onUsbmuxUnattached?.(); + return null; + } + params.onError(endpoint, error); + return null; + } +} + +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 tryRunnerSimulatorEndpoint( + device: DeviceInfo, + port: number, + command: RunnerCommand, + params: { + signal?: AbortSignal; + attemptDeadline?: Deadline; + onError: (error: unknown) => void; + }, +): Promise { + const { signal, attemptDeadline, onError } = params; + const remainingMs = attemptDeadline?.remainingMs() ?? RUNNER_COMMAND_TIMEOUT_MS; + if (remainingMs <= 0) return null; + try { + const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); + return new Response(simResponse.body, { status: simResponse.status }); + } catch (err) { + if (signal?.aborted || isRequestCanceledError(err)) { + throw createRequestCanceledError(); + } + onError(err); + return null; + } +} + +async function postCommandViaSimulator( + device: DeviceInfo, + port: number, + command: RunnerCommand, + timeoutMs: number, + signal?: AbortSignal, +): Promise<{ status: number; body: string }> { + const payload = JSON.stringify(command); + const args = buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + '/usr/bin/curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '--data', + payload, + `http://127.0.0.1:${port}/command`, + ]); + const result = requireExecSuccess( + await runXcrun(args, { allowFailure: true, timeoutMs, signal }), + 'Runner did not accept connection (simctl spawn)', + (result) => { + const reason = classifyBootFailure({ + message: 'Runner did not accept connection (simctl spawn)', + stdout: result.stdout, + stderr: result.stderr, + context: { platform: 'ios', phase: 'connect' }, + }); + return { + port, + reason, + hint: bootFailureHint(reason), + }; + }, + ); + const body = result.stdout as string; + return { status: 200, body }; +} diff --git a/src/platforms/apple/core/runner/runner-transport.ts b/src/platforms/apple/core/runner/runner-transport.ts index 075d60f48..3db2d8e39 100644 --- a/src/platforms/apple/core/runner/runner-transport.ts +++ b/src/platforms/apple/core/runner/runner-transport.ts @@ -1,280 +1,13 @@ -import { createRequestCanceledError, isRequestCanceledError } from '../../../../request/cancel.ts'; +import { createRequestCanceledError } from '../../../../request/cancel.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { requireExecSuccess } from '../../../../utils/exec.ts'; -import { Deadline, retryWithPolicy } from '../../../../utils/retry.ts'; +import { Deadline } from '../../../../utils/retry.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { classifyBootFailure, bootFailureHint } from '../../../boot-diagnostics.ts'; import { resolveIosPhysicalDeviceControl } from '../physical-device-control.ts'; -import { buildSimctlArgsForDevice } from '../simctl.ts'; -import { runXcrun } from '../tool-provider.ts'; -import { - createRunnerCommandRouteResolver, - invalidateDeviceTunnelIpCache, - type RunnerCommandRoute, -} from './runner-command-route.ts'; -import { - buildRunnerConnectError, - buildRunnerEarlyExitError, - isUsbmuxDeviceUnattachedError, - shouldRetryRunnerConnectError, - type RunnerCommand, -} from './runner-contract.ts'; -import type { RunnerSession } from './runner-session-types.ts'; +import { createRunnerCommandRouteResolver } from './runner-command-route.ts'; +import { isUsbmuxDeviceUnattachedError, type RunnerCommand } from './runner-contract.ts'; import { usbmuxRunnerTransport } from './runner-usbmux.ts'; -export { cleanupTempFile, getFreePort, logChunk } from './runner-io.ts'; - -export const RUNNER_STARTUP_TIMEOUT_MS = 45_000; export const RUNNER_COMMAND_TIMEOUT_MS = 45_000; -const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = 250; -const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = 300; -const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = 2_000; -const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = 20_000; -export const RUNNER_DESTINATION_TIMEOUT_SECONDS = 20; - -export async function waitForRunner( - device: DeviceInfo, - port: number, - command: RunnerCommand, - logPath?: string, - timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS, - session?: RunnerSession, - signal?: AbortSignal, -): Promise { - const deadline = Deadline.fromTimeoutMs(timeoutMs); - const { resolveRoute, markUsbmuxUnattached } = createRunnerCommandRouteResolver(device, port); - let route = await resolveRoute(deadline.remainingMs()); - let lastError: unknown = null; - const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); - try { - return await retryWithPolicy( - async ({ deadline: attemptDeadline }) => { - const response = await attemptRunnerConnection({ - device, - port, - command, - timeoutMs, - logPath, - session, - route, - resolveRoute, - markUsbmuxUnattached, - signal, - attemptDeadline, - setRoute: (nextRoute) => { - route = nextRoute; - }, - setLastError: (err) => { - lastError = err; - }, - }); - if (response) return response; - throw buildRunnerEndpointProbeError({ - port, - endpoints: route.endpoints, - lastError, - signal, - }); - }, - { - maxAttempts, - baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS, - maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS, - jitter: 0.2, - shouldRetry: shouldRetryRunnerConnectError, - }, - { deadline, phase: 'ios_runner_connect', signal }, - ); - } catch (error) { - if (signal?.aborted || isRequestCanceledError(error)) { - throw createRequestCanceledError(); - } - if (isUsbmuxDeviceUnattachedError(error)) throw error; - if (!lastError) { - lastError = error; - } - } - - if (signal?.aborted) { - throw createRequestCanceledError(); - } - - if (device.kind === 'simulator') { - const remainingMs = deadline.remainingMs(); - if (remainingMs <= 0) { - throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); - } - const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); - return new Response(simResponse.body, { status: simResponse.status }); - } - - throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); -} - -type RunnerRouteResolver = ReturnType['resolveRoute']; - -async function attemptRunnerConnection(params: { - device: DeviceInfo; - port: number; - command: RunnerCommand; - timeoutMs: number; - logPath?: string; - session?: RunnerSession; - route: RunnerCommandRoute; - resolveRoute: RunnerRouteResolver; - markUsbmuxUnattached: () => void; - signal?: AbortSignal; - attemptDeadline?: Deadline; - setRoute: (route: RunnerCommandRoute) => void; - setLastError: (error: unknown) => void; -}): Promise { - await ensureRunnerAttemptCanStart(params); - - const primary = await tryPrimaryRunnerRoute(params); - if (primary.response) return primary.response; - - const simulatorFallback = await tryReadySimulatorEndpoint(params); - if (simulatorFallback) return simulatorFallback; - - return await tryRefreshedDeviceTunnel(params, primary.usedCachedTunnelIp); -} - -async function ensureRunnerAttemptCanStart(params: { - port: number; - timeoutMs: number; - logPath?: string; - session?: RunnerSession; - attemptDeadline?: Deadline; -}): Promise { - if (params.attemptDeadline?.isExpired()) { - throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { - port: params.port, - timeoutMs: params.timeoutMs, - }); - } - if (params.session?.child.exitCode !== null && params.session?.child.exitCode !== undefined) { - throw await buildRunnerEarlyExitError({ - session: params.session, - port: params.port, - logPath: params.logPath, - }); - } -} - -async function tryPrimaryRunnerRoute(params: { - device: DeviceInfo; - port: number; - command: RunnerCommand; - timeoutMs: number; - route: RunnerCommandRoute; - resolveRoute: RunnerRouteResolver; - signal?: AbortSignal; - attemptDeadline?: Deadline; - setRoute: (route: RunnerCommandRoute) => void; - setLastError: (error: unknown) => void; - markUsbmuxUnattached: () => void; -}): Promise<{ response: Response | null; usedCachedTunnelIp: boolean }> { - let route = params.route; - let usedCachedTunnelIp = false; - if (params.device.kind === 'device') { - route = await params.resolveRoute(params.attemptDeadline?.remainingMs()); - usedCachedTunnelIp = route.cachedTunnelIp; - params.setRoute(route); - } - - const runRoute = async (current: RunnerCommandRoute) => { - // Derived per route: a usbmux-first attempt only learns its cached tunnel - // endpoint after falling back, and a stale one must still be invalidated. - const cachedTunnelEndpoint = current.cachedTunnelIp ? current.endpoints[0] : null; - return await tryRunnerRoute(params.device, current, { - command: params.command, - port: params.port, - timeoutMs: params.timeoutMs, - signal: params.signal, - attemptDeadline: params.attemptDeadline, - onUsbmuxUnattached: params.markUsbmuxUnattached, - onError: (endpoint, err) => { - params.setLastError(err); - if (params.device.kind === 'device' && endpoint === cachedTunnelEndpoint) { - invalidateDeviceTunnelIpCache(params.device.id); - } - }, - }); - }; - - const response = await runRoute(route); - if (response || route.kind !== 'usbmux') return { response, usedCachedTunnelIp }; - - // usbmux reported the device as unattached: resolve the CoreDevice tunnel - // route and try it within the same attempt instead of burning a retry. - const fallback = await params.resolveRoute(params.attemptDeadline?.remainingMs()); - if (fallback.kind === 'usbmux') return { response: null, usedCachedTunnelIp }; - params.setRoute(fallback); - return { response: await runRoute(fallback), usedCachedTunnelIp: fallback.cachedTunnelIp }; -} - -async function tryReadySimulatorEndpoint(params: { - device: DeviceInfo; - port: number; - command: RunnerCommand; - session?: RunnerSession; - signal?: AbortSignal; - attemptDeadline?: Deadline; - setLastError: (error: unknown) => void; -}): Promise { - if (params.device.kind !== 'simulator' || !params.session?.ready) return null; - return await tryRunnerSimulatorEndpoint(params.device, params.port, params.command, { - signal: params.signal, - attemptDeadline: params.attemptDeadline, - onError: params.setLastError, - }); -} - -async function tryRefreshedDeviceTunnel( - params: { - device: DeviceInfo; - port: number; - command: RunnerCommand; - timeoutMs: number; - resolveRoute: RunnerRouteResolver; - signal?: AbortSignal; - attemptDeadline?: Deadline; - setRoute: (route: RunnerCommandRoute) => void; - setLastError: (error: unknown) => void; - }, - usedCachedTunnelIp: boolean, -): Promise { - if (params.device.kind !== 'device' || !usedCachedTunnelIp) return null; - invalidateDeviceTunnelIpCache(params.device.id); - const refreshed = await params.resolveRoute(params.attemptDeadline?.remainingMs(), true); - params.setRoute(refreshed); - return await tryRunnerRoute(params.device, refreshed, { - command: params.command, - port: params.port, - timeoutMs: params.timeoutMs, - signal: params.signal, - attemptDeadline: params.attemptDeadline, - onError: (_endpoint, err) => { - params.setLastError(err); - }, - }); -} - -function buildRunnerEndpointProbeError(params: { - port: number; - endpoints: string[]; - lastError: unknown; - signal?: AbortSignal; -}): AppError { - if (params.signal?.aborted) { - throw createRequestCanceledError(); - } - return new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', { - port: params.port, - endpoints: params.endpoints, - lastError: params.lastError ? String(params.lastError) : undefined, - }); -} export async function sendRunnerCommandOnce( device: DeviceInfo, @@ -343,129 +76,12 @@ async function postUsbmuxRunnerCommand( * network tunnel instead. XCTest-backed devices have no such tunnel, so their * usbmux verdict stands. */ -function canFallBackFromUsbmux(device: DeviceInfo, error: unknown): boolean { +export function canFallBackFromUsbmux(device: DeviceInfo, error: unknown): boolean { if (!isUsbmuxDeviceUnattachedError(error)) return false; return resolveIosPhysicalDeviceControl(device).backend !== 'xctest'; } -async function tryRunnerRoute( - device: DeviceInfo, - route: RunnerCommandRoute, - params: { - command: RunnerCommand; - port: number; - timeoutMs: number; - signal?: AbortSignal; - attemptDeadline?: Deadline; - onUsbmuxUnattached?: () => void; - onError: (endpoint: string, error: unknown) => void; - }, -): Promise { - if (route.kind === 'network') { - return await tryRunnerEndpoints(route.endpoints, params); - } - const endpoint = route.endpoints[0]; - try { - const remainingMs = params.attemptDeadline?.remainingMs() ?? params.timeoutMs; - if (remainingMs <= 0) { - throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { - port: params.port, - timeoutMs: params.timeoutMs, - }); - } - return await usbmuxRunnerTransport.postCommand( - device.id, - params.port, - params.command, - Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs), - params.signal, - ); - } catch (error) { - if (params.signal?.aborted || isRequestCanceledError(error)) { - throw createRequestCanceledError(); - } - if (isUsbmuxDeviceUnattachedError(error)) { - if (!canFallBackFromUsbmux(device, error)) { - // No tunnel exists for this device, so retrying cannot attach a cable. - // Throw the typed verdict so its recovery hint survives instead of - // being replaced by a generic connect failure. - throw error; - } - params.onUsbmuxUnattached?.(); - return null; - } - params.onError(endpoint, error); - return null; - } -} - -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 tryRunnerSimulatorEndpoint( - device: DeviceInfo, - port: number, - command: RunnerCommand, - params: { - signal?: AbortSignal; - attemptDeadline?: Deadline; - onError: (error: unknown) => void; - }, -): Promise { - const { signal, attemptDeadline, onError } = params; - const remainingMs = attemptDeadline?.remainingMs() ?? RUNNER_COMMAND_TIMEOUT_MS; - if (remainingMs <= 0) return null; - try { - const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); - return new Response(simResponse.body, { status: simResponse.status }); - } catch (err) { - if (signal?.aborted || isRequestCanceledError(err)) { - throw createRequestCanceledError(); - } - onError(err); - return null; - } -} - -async function fetchWithTimeout( +export async function fetchWithTimeout( url: string, init: RequestInit, timeoutMs: number, @@ -475,45 +91,3 @@ async function fetchWithTimeout( const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; return await fetch(url, { ...init, signal }); } - -async function postCommandViaSimulator( - device: DeviceInfo, - port: number, - command: RunnerCommand, - timeoutMs: number, - signal?: AbortSignal, -): Promise<{ status: number; body: string }> { - const payload = JSON.stringify(command); - const args = buildSimctlArgsForDevice(device, [ - 'spawn', - device.id, - '/usr/bin/curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/json', - '--data', - payload, - `http://127.0.0.1:${port}/command`, - ]); - const result = requireExecSuccess( - await runXcrun(args, { allowFailure: true, timeoutMs, signal }), - 'Runner did not accept connection (simctl spawn)', - (result) => { - const reason = classifyBootFailure({ - message: 'Runner did not accept connection (simctl spawn)', - stdout: result.stdout, - stderr: result.stderr, - context: { platform: 'ios', phase: 'connect' }, - }); - return { - port, - reason, - hint: bootFailureHint(reason), - }; - }, - ); - const body = result.stdout as string; - return { status: 200, body }; -}