From 3db5e066db29898a6ed1a9b2e55824b374b7ba5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 12:33:32 +0200 Subject: [PATCH] test(ios): pin a read-only cancel in the connect retry delay A read-only command sent to a starting runner retries refused connects. A cancellation that lands in the retry delay after a refused attempt now has a test at the exchange: the command is reported `dispatched: no`, its charge is withdrawn, and no status poll is sent. The rule that an attempt failure counts as possibly written unless it proves otherwise gets one local helper in waitForRunner instead of two copies. Co-Authored-By: Claude Opus 5.5 --- .../runner/__tests__/runner-exchange.test.ts | 34 +++++++++++++++++++ .../src/runner/runner-startup-transport.ts | 9 ++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts index d4e53a3c5a..b5b4b31e9d 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -26,6 +26,7 @@ import { let server: FakeRunnerServer | undefined; afterEach(async () => { + vi.unstubAllGlobals(); await server?.close(); server = undefined; }); @@ -408,6 +409,39 @@ test('a drain whose status read fails stops at once and leaves the charge abando assert.equal(session.commandCharges.hasAbandonedCharges, true); }); +test('a read-only command canceled in the connect retry delay withdraws its charge', async () => { + const session = sessionFor(await closedLocalPort()); + session.state = 'starting'; + const controller = new AbortController(); + const refusedAttempts: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + refusedAttempts.push(url); + setTimeout(() => controller.abort(createRequestCanceledError()), 0); + throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + }), + ); + + await assert.rejects( + executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.app' }, + undefined, + 10_000, + async () => {}, + controller.signal, + ), + (error: unknown) => + isRequestCanceledError(error) && + (error as { details?: { dispatched?: string } }).details?.dispatched === 'no', + ); + + assert.equal(refusedAttempts.length, 1); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + test('a refused connection withdraws its charge although nothing canceled it', async () => { const port = await closedLocalPort(); const session = sessionFor(port); diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index f1aa7c4c52..3c156ca640 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -79,6 +79,9 @@ export async function waitForRunner( // Every attempt posts the command itself, so the loop gives up "before send" only when no // attempt could have written it. let commandMayHaveBeenWritten = false; + const recordAttemptWriteEvidence = (error: unknown): void => { + if (!isRunnerCommandProvablyUnwritten(error)) commandMayHaveBeenWritten = true; + }; const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); try { return await retryWithPolicy( @@ -100,12 +103,10 @@ export async function waitForRunner( }, setLastError: (err) => { lastError = err; - if (!isRunnerCommandProvablyUnwritten(err)) commandMayHaveBeenWritten = true; + recordAttemptWriteEvidence(err); }, }).catch((error: unknown) => { - if (isRequestCanceledError(error) && !isRunnerCommandProvablyUnwritten(error)) { - commandMayHaveBeenWritten = true; - } + if (isRequestCanceledError(error)) recordAttemptWriteEvidence(error); throw error; }); if (response) return response;