From 2c90df9ffe28aef9c04575dfe8563e08dd608150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 17:40:03 +0200 Subject: [PATCH 1/2] fix: improve ios runner crash diagnostics --- .../ios/__tests__/runner-client.test.ts | 69 +++++++++++ .../ios/runner-failure-diagnostics.ts | 107 ++++++++++++++++++ src/platforms/ios/runner-session.ts | 20 ++-- 3 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/platforms/ios/runner-failure-diagnostics.ts diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 1c3f8ea11..8e53ed948 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -686,6 +686,67 @@ test('parseRunnerResponse preserves XCTest recorded failure code and hint', asyn ); }); +test('parseRunnerResponse classifies target app AXRuntime CoreText font crashes from runner log tail', async () => { + const logPath = writeRunnerLogTail(` +Thread 0 Crashed:: Dispatch queue: com.apple.main-thread +0 libobjc.A.dylib objc_retain + 16 +1 CoreText CreateFontWithFontURL(__CFURL const*, __CFString const*, __CFString const*) + 512 +11 AXRuntime reconstitutedSmuggledCTFontFromDictionary + 192 +12 AXRuntime -[NSDictionary(AXPropertyListCoersion) _axRecursivelyReconstitutedRepresentationFromPropertyListWithError:] + 156 +`); + const response = new Response( + JSON.stringify({ + ok: false, + error: { + code: 'XCTEST_RECORDED_FAILURE', + message: + 'XCTest recorded a failure while executing type; the action may not have been performed.', + }, + }), + ); + const session = { ready: true }; + + await assert.rejects( + () => parseRunnerResponse(response, session, logPath), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'IOS_TARGET_APP_CRASH'); + assert.equal(error.details?.runnerFailureReason, 'target_app_axruntime_coretext_crash'); + assert.match(String(error.details?.hint), /AXRuntime read accessibility attributes/); + assert.match(String(error.details?.hint), /latest stable simulator runtime/); + assert.match(String(error.details?.hint), /exact command, selector\/ref/); + return true; + }, + ); +}); + +test('parseRunnerResponse keeps ordinary runner failures generic without crash log evidence', async () => { + const logPath = writeRunnerLogTail( + 'AGENT_DEVICE_RUNNER_COMMAND_FAILED command=type error=main thread execution timed out', + ); + const response = new Response( + JSON.stringify({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'main thread execution timed out', + }, + }), + ); + const session = { ready: true }; + + await assert.rejects( + () => parseRunnerResponse(response, session, logPath), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.runnerFailureReason, undefined); + assert.equal(error.details?.hint, undefined); + return true; + }, + ); +}); + test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async () => { const response = new Response( JSON.stringify({ @@ -710,6 +771,14 @@ test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async assert.match(diagnostics, /xctest-coordinate-drag/); }); +function writeRunnerLogTail(contents: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-log-')); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + const logPath = path.join(dir, 'runner.log'); + fs.writeFileSync(logPath, contents); + return logPath; +} + test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => { const err = new AppError( 'COMMAND_FAILED', diff --git a/src/platforms/ios/runner-failure-diagnostics.ts b/src/platforms/ios/runner-failure-diagnostics.ts new file mode 100644 index 000000000..a21e708f2 --- /dev/null +++ b/src/platforms/ios/runner-failure-diagnostics.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { AppError, type AppErrorCode } from '../../utils/errors.ts'; + +const RUNNER_LOG_TAIL_BYTES = 64 * 1024; + +type RunnerFailureDiagnostic = { + code?: AppErrorCode; + reason: string; + hint: string; +}; + +const IOS_TARGET_AX_CRASH_HINT = + 'The target iOS app appears to have crashed while XCTest/AXRuntime read accessibility attributes. This is usually a simulator/XCTest/runtime or app accessibility payload issue, not a text-entry failure. Reproduce on the latest stable simulator runtime, reinstall the app, and capture the app crash from Console.app or ~/Library/Logs/DiagnosticReports with the exact command, selector/ref, app build, Xcode, and simulator runtime.'; + +const IOS_TARGET_APP_CRASH_HINT = + 'The target iOS app appears to have crashed while the runner was executing the command. Reopen or reinstall the app, retry on a fresh/latest stable simulator runtime, and capture the app crash from Console.app or ~/Library/Logs/DiagnosticReports with the exact command, selector/ref, app build, Xcode, and simulator runtime.'; + +export async function enrichRunnerFailureFromLog(params: { + error: AppError; + logPath?: string; +}): Promise { + const diagnostic = await resolveRunnerFailureDiagnostic(params.logPath); + if (!diagnostic) return params.error; + + return new AppError( + diagnostic.code ?? params.error.code, + params.error.message, + { + ...(params.error.details ?? {}), + hint: + typeof params.error.details?.hint === 'string' + ? `${params.error.details.hint} ${diagnostic.hint}` + : diagnostic.hint, + runnerFailureReason: diagnostic.reason, + }, + params.error, + ); +} + +export async function resolveRunnerFailureDiagnostic( + logPath: string | undefined, +): Promise { + if (!logPath) return undefined; + const tail = await readFileTail(logPath, RUNNER_LOG_TAIL_BYTES); + if (!tail) return undefined; + return classifyRunnerFailureLog(tail); +} + +export function classifyRunnerFailureLog(logText: string): RunnerFailureDiagnostic | undefined { + const normalized = logText.toLowerCase(); + if (isAxRuntimeAccessibilityCrash(normalized)) { + return { + code: 'IOS_TARGET_APP_CRASH', + reason: 'target_app_axruntime_coretext_crash', + hint: IOS_TARGET_AX_CRASH_HINT, + }; + } + if (isTargetAppCrash(normalized)) { + return { + code: 'IOS_TARGET_APP_CRASH', + reason: 'target_app_crash', + hint: IOS_TARGET_APP_CRASH_HINT, + }; + } + return undefined; +} + +function isAxRuntimeAccessibilityCrash(normalized: string): boolean { + return ( + normalized.includes('axruntime') && + normalized.includes('coretext') && + (normalized.includes('attributesforelement') || + normalized.includes('axuielementcopymultipleattributevalues') || + normalized.includes('reconstitutedsmuggledctfontfromdictionary') || + normalized.includes('reconstitutedsmuggledattributedstringfromdictionary')) + ); +} + +function isTargetAppCrash(normalized: string): boolean { + return ( + normalized.includes('process crashed') || + normalized.includes('the application under test') || + normalized.includes('terminated unexpectedly') || + (normalized.includes('exception type:') && normalized.includes('thread 0 crashed')) || + (normalized.includes('crashed') && normalized.includes('xctest')) + ); +} + +async function readFileTail(filePath: string, maxBytes: number): Promise { + let handle: FileHandle | undefined; + try { + const stat = await fs.stat(filePath); + const start = Math.max(0, stat.size - maxBytes); + const length = stat.size - start; + if (length <= 0) return undefined; + + handle = await fs.open(filePath, 'r'); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + return buffer.toString('utf8'); + } catch { + return undefined; + } finally { + await handle?.close().catch(() => {}); + } +} diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 83e5fbcd1..01749d47f 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -46,6 +46,7 @@ import { RUNNER_INVALIDATE_WAIT_TIMEOUT_MS, stopRunnerPrepProcesses, } from './runner-disposal.ts'; +import { enrichRunnerFailureFromLog } from './runner-failure-diagnostics.ts'; import type { RunnerSession } from './runner-session-types.ts'; export type { RunnerSession } from './runner-session-types.ts'; @@ -693,14 +694,17 @@ export async function parseRunnerResponse( : 'COMMAND_FAILED'; const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined; const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined; - throw new AppError(errorCode, errorMessage ?? 'Runner error', { - runner: json, - xcodebuild: { - exitCode: 1, - stdout: '', - stderr: '', - }, - hint, + throw await enrichRunnerFailureFromLog({ + error: new AppError(errorCode, errorMessage ?? 'Runner error', { + runner: json, + xcodebuild: { + exitCode: 1, + stdout: '', + stderr: '', + }, + hint, + logPath, + }), logPath, }); } From a5bde197aaba3957ca47d37dfba915203294be7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 18:08:23 +0200 Subject: [PATCH 2/2] fix: keep runner failure helpers internal --- src/platforms/ios/runner-failure-diagnostics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platforms/ios/runner-failure-diagnostics.ts b/src/platforms/ios/runner-failure-diagnostics.ts index a21e708f2..f0002b123 100644 --- a/src/platforms/ios/runner-failure-diagnostics.ts +++ b/src/platforms/ios/runner-failure-diagnostics.ts @@ -38,7 +38,7 @@ export async function enrichRunnerFailureFromLog(params: { ); } -export async function resolveRunnerFailureDiagnostic( +async function resolveRunnerFailureDiagnostic( logPath: string | undefined, ): Promise { if (!logPath) return undefined; @@ -47,7 +47,7 @@ export async function resolveRunnerFailureDiagnostic( return classifyRunnerFailureLog(tail); } -export function classifyRunnerFailureLog(logText: string): RunnerFailureDiagnostic | undefined { +function classifyRunnerFailureLog(logText: string): RunnerFailureDiagnostic | undefined { const normalized = logText.toLowerCase(); if (isAxRuntimeAccessibilityCrash(normalized)) { return {