Skip to content

Commit 4f0886d

Browse files
authored
fix: improve iOS runner crash diagnostics (#793)
* fix: improve ios runner crash diagnostics * fix: keep runner failure helpers internal
1 parent 96534e8 commit 4f0886d

3 files changed

Lines changed: 188 additions & 8 deletions

File tree

src/platforms/ios/__tests__/runner-client.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,67 @@ test('parseRunnerResponse preserves XCTest recorded failure code and hint', asyn
686686
);
687687
});
688688

689+
test('parseRunnerResponse classifies target app AXRuntime CoreText font crashes from runner log tail', async () => {
690+
const logPath = writeRunnerLogTail(`
691+
Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
692+
0 libobjc.A.dylib objc_retain + 16
693+
1 CoreText CreateFontWithFontURL(__CFURL const*, __CFString const*, __CFString const*) + 512
694+
11 AXRuntime reconstitutedSmuggledCTFontFromDictionary + 192
695+
12 AXRuntime -[NSDictionary(AXPropertyListCoersion) _axRecursivelyReconstitutedRepresentationFromPropertyListWithError:] + 156
696+
`);
697+
const response = new Response(
698+
JSON.stringify({
699+
ok: false,
700+
error: {
701+
code: 'XCTEST_RECORDED_FAILURE',
702+
message:
703+
'XCTest recorded a failure while executing type; the action may not have been performed.',
704+
},
705+
}),
706+
);
707+
const session = { ready: true };
708+
709+
await assert.rejects(
710+
() => parseRunnerResponse(response, session, logPath),
711+
(error: unknown) => {
712+
assert.ok(error instanceof AppError);
713+
assert.equal(error.code, 'IOS_TARGET_APP_CRASH');
714+
assert.equal(error.details?.runnerFailureReason, 'target_app_axruntime_coretext_crash');
715+
assert.match(String(error.details?.hint), /AXRuntime read accessibility attributes/);
716+
assert.match(String(error.details?.hint), /latest stable simulator runtime/);
717+
assert.match(String(error.details?.hint), /exact command, selector\/ref/);
718+
return true;
719+
},
720+
);
721+
});
722+
723+
test('parseRunnerResponse keeps ordinary runner failures generic without crash log evidence', async () => {
724+
const logPath = writeRunnerLogTail(
725+
'AGENT_DEVICE_RUNNER_COMMAND_FAILED command=type error=main thread execution timed out',
726+
);
727+
const response = new Response(
728+
JSON.stringify({
729+
ok: false,
730+
error: {
731+
code: 'COMMAND_FAILED',
732+
message: 'main thread execution timed out',
733+
},
734+
}),
735+
);
736+
const session = { ready: true };
737+
738+
await assert.rejects(
739+
() => parseRunnerResponse(response, session, logPath),
740+
(error: unknown) => {
741+
assert.ok(error instanceof AppError);
742+
assert.equal(error.code, 'COMMAND_FAILED');
743+
assert.equal(error.details?.runnerFailureReason, undefined);
744+
assert.equal(error.details?.hint, undefined);
745+
return true;
746+
},
747+
);
748+
});
749+
689750
test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async () => {
690751
const response = new Response(
691752
JSON.stringify({
@@ -710,6 +771,14 @@ test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async
710771
assert.match(diagnostics, /xctest-coordinate-drag/);
711772
});
712773

774+
function writeRunnerLogTail(contents: string): string {
775+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-log-'));
776+
onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true }));
777+
const logPath = path.join(dir, 'runner.log');
778+
fs.writeFileSync(logPath, contents);
779+
return logPath;
780+
}
781+
713782
test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => {
714783
const err = new AppError(
715784
'COMMAND_FAILED',
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fs from 'node:fs/promises';
2+
import type { FileHandle } from 'node:fs/promises';
3+
import { AppError, type AppErrorCode } from '../../utils/errors.ts';
4+
5+
const RUNNER_LOG_TAIL_BYTES = 64 * 1024;
6+
7+
type RunnerFailureDiagnostic = {
8+
code?: AppErrorCode;
9+
reason: string;
10+
hint: string;
11+
};
12+
13+
const IOS_TARGET_AX_CRASH_HINT =
14+
'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.';
15+
16+
const IOS_TARGET_APP_CRASH_HINT =
17+
'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.';
18+
19+
export async function enrichRunnerFailureFromLog(params: {
20+
error: AppError;
21+
logPath?: string;
22+
}): Promise<AppError> {
23+
const diagnostic = await resolveRunnerFailureDiagnostic(params.logPath);
24+
if (!diagnostic) return params.error;
25+
26+
return new AppError(
27+
diagnostic.code ?? params.error.code,
28+
params.error.message,
29+
{
30+
...(params.error.details ?? {}),
31+
hint:
32+
typeof params.error.details?.hint === 'string'
33+
? `${params.error.details.hint} ${diagnostic.hint}`
34+
: diagnostic.hint,
35+
runnerFailureReason: diagnostic.reason,
36+
},
37+
params.error,
38+
);
39+
}
40+
41+
async function resolveRunnerFailureDiagnostic(
42+
logPath: string | undefined,
43+
): Promise<RunnerFailureDiagnostic | undefined> {
44+
if (!logPath) return undefined;
45+
const tail = await readFileTail(logPath, RUNNER_LOG_TAIL_BYTES);
46+
if (!tail) return undefined;
47+
return classifyRunnerFailureLog(tail);
48+
}
49+
50+
function classifyRunnerFailureLog(logText: string): RunnerFailureDiagnostic | undefined {
51+
const normalized = logText.toLowerCase();
52+
if (isAxRuntimeAccessibilityCrash(normalized)) {
53+
return {
54+
code: 'IOS_TARGET_APP_CRASH',
55+
reason: 'target_app_axruntime_coretext_crash',
56+
hint: IOS_TARGET_AX_CRASH_HINT,
57+
};
58+
}
59+
if (isTargetAppCrash(normalized)) {
60+
return {
61+
code: 'IOS_TARGET_APP_CRASH',
62+
reason: 'target_app_crash',
63+
hint: IOS_TARGET_APP_CRASH_HINT,
64+
};
65+
}
66+
return undefined;
67+
}
68+
69+
function isAxRuntimeAccessibilityCrash(normalized: string): boolean {
70+
return (
71+
normalized.includes('axruntime') &&
72+
normalized.includes('coretext') &&
73+
(normalized.includes('attributesforelement') ||
74+
normalized.includes('axuielementcopymultipleattributevalues') ||
75+
normalized.includes('reconstitutedsmuggledctfontfromdictionary') ||
76+
normalized.includes('reconstitutedsmuggledattributedstringfromdictionary'))
77+
);
78+
}
79+
80+
function isTargetAppCrash(normalized: string): boolean {
81+
return (
82+
normalized.includes('process crashed') ||
83+
normalized.includes('the application under test') ||
84+
normalized.includes('terminated unexpectedly') ||
85+
(normalized.includes('exception type:') && normalized.includes('thread 0 crashed')) ||
86+
(normalized.includes('crashed') && normalized.includes('xctest'))
87+
);
88+
}
89+
90+
async function readFileTail(filePath: string, maxBytes: number): Promise<string | undefined> {
91+
let handle: FileHandle | undefined;
92+
try {
93+
const stat = await fs.stat(filePath);
94+
const start = Math.max(0, stat.size - maxBytes);
95+
const length = stat.size - start;
96+
if (length <= 0) return undefined;
97+
98+
handle = await fs.open(filePath, 'r');
99+
const buffer = Buffer.alloc(length);
100+
await handle.read(buffer, 0, length, start);
101+
return buffer.toString('utf8');
102+
} catch {
103+
return undefined;
104+
} finally {
105+
await handle?.close().catch(() => {});
106+
}
107+
}

src/platforms/ios/runner-session.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
RUNNER_INVALIDATE_WAIT_TIMEOUT_MS,
4747
stopRunnerPrepProcesses,
4848
} from './runner-disposal.ts';
49+
import { enrichRunnerFailureFromLog } from './runner-failure-diagnostics.ts';
4950
import type { RunnerSession } from './runner-session-types.ts';
5051

5152
export type { RunnerSession } from './runner-session-types.ts';
@@ -693,14 +694,17 @@ export async function parseRunnerResponse(
693694
: 'COMMAND_FAILED';
694695
const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined;
695696
const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined;
696-
throw new AppError(errorCode, errorMessage ?? 'Runner error', {
697-
runner: json,
698-
xcodebuild: {
699-
exitCode: 1,
700-
stdout: '',
701-
stderr: '',
702-
},
703-
hint,
697+
throw await enrichRunnerFailureFromLog({
698+
error: new AppError(errorCode, errorMessage ?? 'Runner error', {
699+
runner: json,
700+
xcodebuild: {
701+
exitCode: 1,
702+
stdout: '',
703+
stderr: '',
704+
},
705+
hint,
706+
logPath,
707+
}),
704708
logPath,
705709
});
706710
}

0 commit comments

Comments
 (0)