diff --git a/packages/host-kit/src/internal/exec.test.ts b/packages/host-kit/src/internal/exec.test.ts index fedc7daaee..6009a3f6a9 100644 --- a/packages/host-kit/src/internal/exec.test.ts +++ b/packages/host-kit/src/internal/exec.test.ts @@ -483,3 +483,20 @@ test('commandHostEnvironment names the enclosing host and refuses outside one', assert.equal(commandHostEnvironment(), host); }); }); + +// Windows reports no signal for a terminated process. +test.skipIf(process.platform === 'win32')( + 'every foreground executor reports the signal that ended the process', + async () => { + const kill = ['-e', "process.kill(process.pid, 'SIGKILL')"]; + const options = { allowFailure: true }; + + assert.equal((await runCmd(process.execPath, kill, options)).signal, 'SIGKILL'); + assert.equal((await runCmdStreaming(process.execPath, kill, options)).signal, 'SIGKILL'); + assert.equal(runCmdSync(process.execPath, kill, options).signal, 'SIGKILL'); + + const exited = await runCmd(process.execPath, ['-e', 'process.exit(3)'], options); + assert.equal(exited.exitCode, 3); + assert.equal('signal' in exited, false); + }, +); diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 181bd7b1a3..e9611933a6 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -14,6 +14,8 @@ export type ExecResult = { stderr: string; exitCode: number; stdoutBuffer?: Buffer; + /** The signal that ended the process, when one did. */ + signal?: string; }; export type ExecOptions = { @@ -46,7 +48,8 @@ export type ExecStreamOptions = ExecOptions & { export type ExecBackgroundResult = { child: ReturnType; - wait: Promise; + /** A background run reports no `signal`: its caller owns the child and reads its exit directly. */ + wait: Promise>; }; type ExecDetachedOptions = ExecOptions & { @@ -268,12 +271,14 @@ function runSpawnedCommand( reject(failure); return; } - resolve({ - stdout, - stderr, - exitCode: finalExitCode, - stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : undefined, - }); + resolve( + execResult(child, { + stdout, + stderr, + exitCode: finalExitCode, + stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : undefined, + }), + ); } // A deadline that fires after the child exited on its own still has to settle: the // group kill that would have ended the pipe holder can no longer run through a child @@ -336,6 +341,11 @@ function runSpawnedCommand( }); } +/** Returns the result with `signal` added when a signal terminated the process. */ +function execResult(ended: { signalCode: string | null }, result: ExecResult): ExecResult { + return ended.signalCode ? { ...result, signal: ended.signalCode } : result; +} + export async function whichCmd(cmd: string, env: EnvMap): Promise { const candidate = normalizeExecutableLookup(cmd); if (!candidate) return false; @@ -449,7 +459,7 @@ export function runCmdSync( throw createExitError(executable, cmd, args, exitCode, stdout, stderr); } - return { stdout, stderr, exitCode, stdoutBuffer }; + return execResult({ signalCode: result.signal }, { stdout, stderr, exitCode, stdoutBuffer }); } export function runCmdDetached( @@ -524,7 +534,7 @@ export function runCmdBackground( }); } - const wait = new Promise((resolve, reject) => { + const wait = new Promise>((resolve, reject) => { let settled = false; function finish(event: 'error' | 'exit'): boolean { if (settled) return false; diff --git a/packages/platform-android/src/__tests__/snapshot-helper-exit-result.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-exit-result.test.ts new file mode 100644 index 0000000000..4ea23f6649 --- /dev/null +++ b/packages/platform-android/src/__tests__/snapshot-helper-exit-result.test.ts @@ -0,0 +1,917 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; +import { + captureAndroidSnapshotWithHelper, + parseAndroidSnapshotHelperOutput, +} from '../snapshot-helper-capture.ts'; +import { runCmd } from '@agent-device/host-kit/command'; +import { captureAndroidSnapshotWithHelperSession } from '../snapshot-helper-session.ts'; +import { waitForAndroidSnapshotHelperSessionReady } from '../snapshot-helper-session-protocol.ts'; +import { recordInstrumentationProcess } from '../instrumentation-helper.ts'; +import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; +import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts'; +import { snapshotAndroid } from '../snapshot.ts'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts'; +import type { AndroidAdbProvider } from '../adb-executor.ts'; +import { + androidHelperRuntimeProbeResult, + createSessionProvider, + FakeAndroidProcess, + isAndroidHelperRuntimeForceStop, + isAndroidHelperRuntimeProbe, +} from './snapshot-helper-session.fixtures.ts'; +import { mkdtempForTest } from './test-utils/tmp-dir.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; + +beforeEach(async () => { + resetAndroidSnapshotHelperRetirements(); + await resetAndroidSnapshotHelperSessions(); +}); + +afterEach(async () => { + await resetAndroidSnapshotHelperSessions(); +}); + +// Instrumentation outputs as `am instrument -w` prints them when the helper never reports. +const ABORTED_RUN = [ + 'INSTRUMENTATION_STATUS: numtests=1', + 'INSTRUMENTATION_STATUS_CODE: 1', + 'INSTRUMENTATION_ABORTED: System has crashed.', + '', +].join('\n'); + +const CRASHED_RUN = [ + 'INSTRUMENTATION_RESULT: shortMsg=java.lang.IllegalStateException', + 'INSTRUMENTATION_RESULT: longMsg=java.lang.IllegalStateException: UiAutomation not connected', + 'INSTRUMENTATION_CODE: 0', + '', +].join('\n'); + +type FailedRun = { exitCode: number; stdout: string; stderr?: string; signal?: string }; + +type ExitResultFields = { + withheldFields?: string[]; + exitCode: number | null; + signal?: string; + resultCode?: number; + statusCode?: number; + aborted?: string; + failed?: string; + shortMsg?: string; + longMsg?: string; + error?: string; + stack?: string; +}; + +const SECRET = 'hunter2-account-number'; +const SECRET_XML = ``; +const SECRET_BASE64 = Buffer.from(SECRET_XML).toString('base64'); + +const emulator: DeviceInfo = { + platform: 'android', + id: 'emulator-5556', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +async function captureError(run: FailedRun): Promise { + try { + await captureAndroidSnapshotWithHelper({ adb: async () => ({ stderr: '', ...run }) }); + } catch (error) { + return error as AppError; + } + throw new Error('expected the capture to fail'); +} + +async function captureExitResult(run: FailedRun): Promise { + return (await captureError(run)).details?.instrumentation as ExitResultFields; +} + +function encodedPayload(xml: string): string { + return `INSTRUMENTATION_STATUS: payloadBase64=${Buffer.from(xml).toString('base64')}`; +} + +function helperFailingWith(run: FailedRun): AndroidAdbExecutor { + return async (args) => { + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', + stderr: '', + }; + } + if (isAndroidHelperRuntimeProbe(args)) return androidHelperRuntimeProbeResult(); + if (isAndroidHelperRuntimeForceStop(args)) return { exitCode: 0, stdout: '', stderr: '' }; + return { stderr: '', ...run }; + }; +} + +async function withLog( + run: (logPath: string) => Promise, +): Promise<{ result: T; log: string }> { + const tmpDir = await mkdtempForTest('agent-device-android-exit-result-'); + const logPath = path.join(tmpDir, 'diag.ndjson'); + try { + const result = await run(logPath); + return { result, log: await fs.readFile(logPath, 'utf8') }; + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +function inScope(logPath: string, body: () => Promise): Promise { + return withDiagnosticsScope( + { debug: true, logPath, session: 'android-test', requestId: 'req-1', command: 'snapshot' }, + body, + ); +} + +function diagnosticData(log: string, phase: string): Record { + const event = log + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { phase?: string; data?: Record }) + .find((entry) => entry.phase === phase); + assert.ok(event, `expected a ${phase} diagnostic`); + return event.data ?? {}; +} + +function helperOf(error: AppError): Record { + return (error.details?.helper ?? {}) as Record; +} + +function exitResultOf(fields: Record): ExitResultFields { + return fields.instrumentation as ExitResultFields; +} + +/** Runs a ready session whose helper process prints through `print` and then exits with 1. */ +async function sessionExit( + print: (process: FakeAndroidProcess) => Promise, +): Promise<{ exit: ExitResultFields; log: string }> { + const { result, log } = await withLog(async (logPath) => { + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls: [], processes }); + const capture = () => + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + await inScope(logPath, async () => { + assert.ok(await capture()); + await print(processes[0]!); + await pause(); + processes[0]!.emitExit(1, null); + await capture(); + }); + return undefined; + }); + void result; + return { + exit: exitResultOf(diagnosticData(log, 'android_snapshot_helper_session_exited')), + log, + }; +} + +/** The AppError a promise rejects with; fails the test when it resolves. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + assert.ok(error instanceof AppError, String(error)); + return error; + } + throw new Error('expected the promise to reject'); +} + +const pause = () => new Promise((resolve) => setTimeout(resolve, 10)); + +function assertPrivate(text: string): void { + assert.ok(!text.includes(SECRET), text.slice(-800)); + assert.ok(!text.includes(SECRET_BASE64.slice(0, 24)), text.slice(-800)); +} + +test('an aborted instrumentation run reports the abort, the status code and the exit status', async () => { + assert.deepEqual(await captureExitResult({ exitCode: 1, stdout: ABORTED_RUN }), { + exitCode: 1, + statusCode: 1, + aborted: 'System has crashed.', + }); +}); + +test('a crashed instrumentation run reports shortMsg, longMsg and the result code', async () => { + assert.deepEqual(await captureExitResult({ exitCode: 0, stdout: CRASHED_RUN }), { + exitCode: 0, + resultCode: 0, + shortMsg: 'java.lang.IllegalStateException', + longMsg: 'java.lang.IllegalStateException: UiAutomation not connected', + }); +}); + +test('the host signal that ended the run reaches the details', async () => { + assert.equal( + (await captureExitResult({ exitCode: 1, stdout: '', signal: 'SIGKILL' })).signal, + 'SIGKILL', + ); +}); + +test('an empty or out-of-range INSTRUMENTATION_CODE is not recorded as a result code', async () => { + const empty = await captureExitResult({ + exitCode: 1, + stdout: 'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\nINSTRUMENTATION_CODE: \n', + }); + assert.equal(empty.resultCode, undefined); + assert.equal(empty.shortMsg, 'Process crashed.'); + + const huge = await captureExitResult({ + exitCode: 1, + stdout: `INSTRUMENTATION_CODE: ${'9'.repeat(400)}\n`, + }); + assert.equal(huge.resultCode, undefined); +}); + +test('multiline protocol values keep their continuation lines until the next record', async () => { + const crlf = await captureExitResult({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: shortMsg=Process crashed.', + 'INSTRUMENTATION_RESULT: longMsg=UiAutomation failed', + 'Caused by: binder has died', + 'INSTRUMENTATION_CODE: 0', + '', + ].join('\r\n'), + }); + assert.equal(crlf.shortMsg, 'Process crashed.'); + assert.equal(crlf.longMsg, 'UiAutomation failed\nCaused by: binder has died'); + assert.equal(crlf.resultCode, 0); + + const stack = await captureExitResult({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_STATUS: Error=java.lang.IllegalStateException: no root', + 'INSTRUMENTATION_STATUS: stack=java.lang.IllegalStateException: no root', + '\tat android.app.UiAutomation.connect(UiAutomation.java:12)', + '\tat com.callstack.agentdevice.snapshothelper.Main.(Main.java:7)', + 'INSTRUMENTATION_STATUS_CODE: -2', + ].join('\n'), + }); + assert.equal(stack.error, 'java.lang.IllegalStateException: no root'); + assert.equal( + stack.stack, + [ + 'java.lang.IllegalStateException: no root', + '\tat android.app.UiAutomation.connect(UiAutomation.java:12)', + '\tat com.callstack.agentdevice.snapshothelper.Main.(Main.java:7)', + ].join('\n'), + ); + assert.equal(stack.statusCode, -2); + + const interleaved = await captureExitResult({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: longMsg=first line', + 'second line', + encodedPayload(SECRET_XML), + 'not part of the message', + ].join('\n'), + }); + assert.equal(interleaved.longMsg, 'first line\nsecond line'); +}); + +test('single-line and multiline messages are held to the message cap', async () => { + const result = await captureExitResult({ + exitCode: 0, + stdout: `INSTRUMENTATION_RESULT: shortMsg=${'s'.repeat(3_000)}\nINSTRUMENTATION_RESULT: longMsg=${'l'.repeat(2_000)}\n${'m'.repeat(2_000)}\nINSTRUMENTATION_CODE: 0`, + }); + + assert.equal(result.shortMsg?.length, 2_048); + assert.ok((result.longMsg?.length ?? 0) <= 2_048); +}); + +test('startup failures are recognized in every envelope AOSP prints', async () => { + const forms = [ + ['INSTRUMENTATION_FAILED: com.example/.Missing', 'com.example/.Missing'], + [ + 'android.util.AndroidException: INSTRUMENTATION_FAILED: com.example/.Missing', + 'com.example/.Missing', + ], + [ + 'onError: commandError=true message=INSTRUMENTATION_FAILED: com.example/.Missing', + 'com.example/.Missing', + ], + ['INSTRUMENTATION_FAILED:', ''], + ] as const; + for (const [line, expected] of forms) { + for (const stream of ['stdout', 'stderr'] as const) { + const run = + stream === 'stdout' ? { stdout: `${line}\n` } : { stdout: '', stderr: `${line}\n` }; + const result = await captureExitResult({ exitCode: 1, ...run }); + assert.equal(result.failed, expected, `${stream}: ${line}`); + } + } +}); + +test('stdout is never copied: only parsed protocol fields reach the details', async () => { + const error = await captureError({ + exitCode: 1, + stdout: `${CRASHED_RUN}some unrelated stdout line\n`, + }); + + assert.equal('stdout' in (error.details ?? {}), false); + assert.ok(!JSON.stringify(error.details).includes('some unrelated stdout line')); +}); + +test('a sensitive value never reaches the instrumentation detail or the log wherever the helper printed it', async () => { + const quoted = `${'p'.repeat(1_015)}password="${SECRET}" trailing`; + const onStdout: FailedRun[] = [ + // A complete encoded payload. + { exitCode: 1, stdout: `${encodedPayload(SECRET_XML)}\nINSTRUMENTATION_STATUS_CODE: 1` }, + // An unknown status key carrying raw XML. + { exitCode: 1, stdout: `INSTRUMENTATION_STATUS: stream=${SECRET_XML}\n` }, + // A protocol record holding an attribute with no opening tag. + { + exitCode: 1, + stdout: `INSTRUMENTATION_STATUS: detail=ret" text="${SECRET}" bounds="[0,0][1,1]" />\n`, + }, + // A tagless fragment as a free line. + { exitCode: 1, stdout: `ret" text="${SECRET}" bounds="[0,0][1,1]" />\n` }, + // A fragment as a continuation of an open message, and inline in each retained value. + { + exitCode: 0, + stdout: `INSTRUMENTATION_RESULT: longMsg=UiAutomation failed\nret" role-description="${SECRET}" />\nINSTRUMENTATION_CODE: 0\n`, + }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_STATUS: Error=\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_STATUS: stack=boom\n\tat x hint="${SECRET}"\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_ABORTED: \n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_ABORTED: ret" content-desc="${SECRET}"\n` }, + // Attributes the dump writes that an attribute list could miss, in any case or quoting. + { + exitCode: 1, + stdout: `INSTRUMENTATION_RESULT: longMsg=boom\ncan-scroll-forward="${SECRET}"\n`, + }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: longMsg=boom\nwindow-bounds="${SECRET}"\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=ret" TEXT="${SECRET}"\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=ret" Text="${SECRET}"\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=ret text='${SECRET}'\n` }, + { + exitCode: 1, + stdout: `INSTRUMENTATION_RESULT: shortMsg=<node text="${SECRET}">\n`, + }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=<${SECRET}\n` }, + // Text after the one-line abort message is not part of it. + { exitCode: 1, stdout: `INSTRUMENTATION_ABORTED: crashed\nret" text="${SECRET}" />\n` }, + // Entities of any kind. + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=a & ${SECRET}\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg=it's ${SECRET}\n` }, + { exitCode: 1, stdout: `INSTRUMENTATION_RESULT: shortMsg='${SECRET}\n` }, + // A private prefix in a line the 4,096-character limit cuts before any marker shows. + { + exitCode: 1, + stdout: `INSTRUMENTATION_RESULT: longMsg=${SECRET}${'x'.repeat(4_200)} text="y"\n`, + }, + { + exitCode: 1, + stdout: `INSTRUMENTATION_RESULT: longMsg=boom\n${SECRET}${'x'.repeat(4_200)} text="y"\n`, + }, + ]; + for (const run of onStdout) { + const error = await captureError({ ...run, stderr: 'am: Killed' }); + assertPrivate(JSON.stringify(normalizeError(error))); + // Stderr exposure stays as on main (#4026); this PR asserts privacy on everything it adds. + const { log } = await withLog(async (logPath) => { + await inScope(logPath, async () => { + await assert.rejects( + snapshotAndroid(emulator, { + helperAdb: helperFailingWith({ ...run, stderr: 'am: Killed' }), + helperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }), + ); + }); + }); + assert.ok(log.includes('android_snapshot_helper_failed')); + assertPrivate(log); + } + + // The failure message and `stderr` detail are unchanged from before this diagnostic existed, so + // for stderr only the new `instrumentation` detail and its request-log entry are asserted. + const onStderr = [ + `ret" text="${SECRET}" bounds="[0,0][1,1]" />`, + `ret" role-description="${SECRET}" />`, + quoted, + `${'a'.repeat(30_000)}${SECRET_XML}`, + ` { + await inScope(logPath, async () => { + await assert.rejects( + snapshotAndroid(emulator, { + helperAdb: helperFailingWith(run), + helperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }), + ); + }); + }); + assertPrivate( + JSON.stringify(diagnosticData(log, 'android_snapshot_helper_failed').instrumentation), + ); + } +}); + +test('a session exit never leaks a sensitive value split across chunks', async () => { + const { exit, log } = await sessionExit(async (process) => { + // The payload and the sensitive key arrive in pieces, with stderr interleaved. + process.stdout.write(`INSTRUMENTATION_STATUS: payloadBase64=${SECRET_BASE64.slice(0, 20)}`); + process.stderr.write('pass'); + await pause(); + process.stdout.write(`${SECRET_BASE64.slice(20)}\nINSTRUMENTATION_STATUS_CODE: 1\n`); + process.stderr.write(`word=${SECRET}\n`); + process.stderr.write(`ret" text="${SECRET}"`); + process.stderr.write(' bounds="[0,0][1,1]" />\nam: Killed\n'); + process.stdout.write('INSTRUMENTATION_ABORTED: System has crashed.\n'); + }); + + assertPrivate(log); + assert.equal(exit.aborted, 'System has crashed.'); +}); + +test('a startup failure attaches no raw helper stream to its details or the log', async () => { + const printed = (process: FakeAndroidProcess) => { + process.stdout.write(`${encodedPayload(SECRET_XML)}\n`); + process.stdout.write(`INSTRUMENTATION_STATUS: stream=${SECRET_XML}\n`); + process.stdout.write(`ret" text="${SECRET}" bounds="[0,0][1,1]" />\n`); + process.stderr.write(`ret" content-desc="${SECRET}" bounds="[0,0][1,1]" />\nam: Killed\n`); + }; + const waitFor = (process: FakeAndroidProcess, timeoutMs: number) => + waitForAndroidSnapshotHelperSessionReady( + process, + timeoutMs, + undefined, + recordInstrumentationProcess(process), + ); + + const exited = new FakeAndroidProcess(); + const exitedFailure = rejection(waitFor(exited, 5_000)); + printed(exited); + await pause(); + exited.emitExit(1, null); + const exitedError = await exitedFailure; + assertPrivate(JSON.stringify(normalizeError(exitedError))); + assert.equal('output' in (exitedError.details ?? {}), false); + assert.equal(exitResultOf(exitedError.details ?? {}).exitCode, 1); + + const stalled = new FakeAndroidProcess(); + const stalledFailure = rejection(waitFor(stalled, 50)); + printed(stalled); + const stalledError = await stalledFailure; + assertPrivate(JSON.stringify(normalizeError(stalledError))); + assert.equal('output' in (stalledError.details ?? {}), false); + assert.equal(exitResultOf(stalledError.details ?? {}).exitCode, null); + + const { log } = await withLog(async (logPath) => { + const base = createSessionProvider({ calls: [], processes: [] }); + const provider: AndroidAdbProvider = { + ...base, + spawn: () => { + const process = new FakeAndroidProcess(); + setImmediate(() => { + printed(process); + process.emitExit(1, null); + }); + return process; + }, + }; + await inScope(logPath, async () => { + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + }); + }); + assertPrivate(log); + assert.equal( + exitResultOf(diagnosticData(log, 'android_snapshot_helper_session_start_failed')).exitCode, + 1, + ); +}); + +test('a nonzero exit after parseable helper output keeps no raw stream in its details', async () => { + const xml = ``; + const stdout = [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: outputFormat=uiautomator-xml', + 'INSTRUMENTATION_STATUS: chunkIndex=0', + 'INSTRUMENTATION_STATUS: chunkCount=1', + `INSTRUMENTATION_STATUS: payloadBase64=${Buffer.from(xml).toString('base64')}`, + 'INSTRUMENTATION_STATUS_CODE: 1', + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_CODE: -1', + ].join('\n'); + const error = await captureError({ exitCode: 1, stdout, stderr: 'am: Killed' }); + + assertPrivate(JSON.stringify(normalizeError(error))); + assert.equal(error.details?.stdout, ''); + assert.equal(error.details?.stderr, 'am: Killed'); + assert.equal(error.details?.exitCode, 1); +}); + +test('stderr chunks do not disturb a partial stdout record', async () => { + const { exit } = await sessionExit(async (process) => { + process.stdout.write('INSTRUMENTATION_RESULT: longMsg=UiAutomation fai'); + process.stderr.write('am: warning\n'); + await pause(); + process.stdout.write('led\nCaused by: binder has died\nINSTRUMENTATION_CODE: 0\n'); + }); + + assert.equal(exit.longMsg, 'UiAutomation failed\nCaused by: binder has died'); + assert.equal(exit.resultCode, 0); +}); + +test('a message holding snapshot markers is withheld whole and named', async () => { + const result = await captureExitResult({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: shortMsg=Process crashed.', + 'INSTRUMENTATION_RESULT: longMsg=UiAutomation failed', + `ret" role-description="${SECRET}" />`, + 'INSTRUMENTATION_STATUS: stack=java.lang.IllegalStateException', + '\tat com.callstack.Main.(Main.java:7)', + 'INSTRUMENTATION_ABORTED: ', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'), + }); + + assert.equal(result.shortMsg, 'Process crashed.'); + assert.equal(result.longMsg, undefined); + assert.equal(result.aborted, undefined); + assert.equal( + result.stack, + 'java.lang.IllegalStateException\n\tat com.callstack.Main.(Main.java:7)', + ); + assert.deepEqual(result.withheldFields, ['aborted', 'longMsg']); +}); + +test('a plain crash message and stack trace are kept', async () => { + const result = await captureExitResult({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: shortMsg=Process crashed.', + 'INSTRUMENTATION_RESULT: longMsg=java.lang.IllegalStateException: binder died', + 'INSTRUMENTATION_STATUS: stack=java.lang.IllegalStateException: binder died', + '\tat com.foo.Bar.(Bar.java:7)', + '\tat com.foo.Bar.baz(Bar.java:12)', + 'INSTRUMENTATION_ABORTED: System has crashed.', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'), + }); + + assert.equal(result.withheldFields, undefined); + assert.equal(result.aborted, 'System has crashed.'); + assert.equal( + result.stack, + 'java.lang.IllegalStateException: binder died\n\tat com.foo.Bar.(Bar.java:7)\n\tat com.foo.Bar.baz(Bar.java:12)', + ); +}); + +test('the abort message is one line: a plain line after it is not copied anywhere', async () => { + const stdout = 'INSTRUMENTATION_ABORTED: System has crashed.\nPLAIN_FOLLOWING_LINE\n'; + const error = await captureError({ exitCode: 1, stdout }); + const { log } = await withLog(async (logPath) => { + await inScope(logPath, async () => { + await assert.rejects( + snapshotAndroid(emulator, { + helperAdb: helperFailingWith({ exitCode: 1, stdout }), + helperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }), + ); + }); + }); + + assert.equal(exitResultOf(error.details ?? {}).aborted, 'System has crashed.'); + assert.ok(!JSON.stringify(normalizeError(error)).includes('PLAIN_FOLLOWING_LINE')); + assert.ok(!log.includes('PLAIN_FOLLOWING_LINE')); +}); + +test('a field is withheld when a line of it was cut by the line limit', async () => { + const result = await captureExitResult({ + exitCode: 0, + stdout: `INSTRUMENTATION_RESULT: longMsg=${'a'.repeat(5_000)}\nINSTRUMENTATION_CODE: 0\n`, + }); + + assert.equal(result.longMsg, undefined); + assert.deepEqual(result.withheldFields, ['longMsg']); +}); + +test('a streamed session withholds a message whose fragment arrives in chunks', async () => { + const { exit, log } = await sessionExit(async (process) => { + process.stdout.write('INSTRUMENTATION_RESULT: longMsg=UiAutomation failed\nret" role-descr'); + await pause(); + process.stdout.write(`iption="${SECRET}" />\nINSTRUMENTATION_CODE: 0\n`); + }); + + assertPrivate(log); + assert.equal(exit.longMsg, undefined); + assert.deepEqual(exit.withheldFields, ['longMsg']); + assert.equal(exit.resultCode, 0); +}); + +test('a helper report without ok keeps the message, metadata and typed reason it had on main', async () => { + const error = await captureError({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: errorType=java.util.concurrent.TimeoutException', + 'INSTRUMENTATION_RESULT: message=uiautomator unavailable', + ].join('\n'), + }); + + assert.equal(error.message, 'uiautomator unavailable'); + assert.equal(readAndroidCaptureFailureReason(error), 'accessibility-timeout'); + assert.equal(helperOf(error).message, 'uiautomator unavailable'); + assert.equal('stderr' in (error.details ?? {}), false); + assert.equal(exitResultOf(error.details ?? {}).exitCode, 0); +}); + +test('an ok=true report with no XML chunks keeps main message and gains the instrumentation detail', async () => { + const error = await captureError({ + exitCode: 1, + stdout: [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_CODE: -1', + ].join('\n'), + stderr: 'am: Killed', + }); + + assert.equal(error.message, 'Android snapshot helper did not return XML chunks'); + assert.equal('stderr' in (error.details ?? {}), false); + assert.equal(exitResultOf(error.details ?? {}).resultCode, -1); +}); + +test('a helper failure that reports its own result keeps that report and gains the instrumentation detail', async () => { + const error = await captureError({ + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: ok=false', + 'INSTRUMENTATION_RESULT: errorType=java.lang.IllegalStateException', + 'INSTRUMENTATION_CODE: -1', + ].join('\n'), + }); + + assert.equal(helperOf(error).ok, 'false'); + assert.equal(exitResultOf(error.details ?? {}).resultCode, -1); +}); + +test('a session that exits before it is ready records its exit status and the output it printed', async () => { + const { log } = await withLog(async (logPath) => { + const base = createSessionProvider({ calls: [], processes: [] }); + const provider: AndroidAdbProvider = { + ...base, + spawn: () => { + const process = new FakeAndroidProcess(); + setImmediate(() => { + process.stdout.write(ABORTED_RUN); + process.stderr.write('am: Killed\n'); + process.emitExit(1, null); + }); + return process; + }, + }; + await inScope(logPath, async () => { + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + assert.equal(output, undefined); + }); + }); + + assert.deepEqual( + exitResultOf(diagnosticData(log, 'android_snapshot_helper_session_start_failed')), + { + exitCode: 1, + statusCode: 1, + aborted: 'System has crashed.', + }, + ); +}); + +test('a session that exits after it was ready records its exit result', async () => { + const { exit } = await sessionExit(async (process) => { + process.stdout.write(CRASHED_RUN); + }); + + assert.equal(exit.exitCode, 1); + assert.equal(exit.shortMsg, 'java.lang.IllegalStateException'); +}); + +test('a session exit result includes output that arrives after the exit event', async () => { + const { log } = await withLog(async (logPath) => { + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls: [], processes }); + const capture = () => + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + await inScope(logPath, async () => { + assert.ok(await capture()); + const process = processes[0]!; + process.exitCode = 1; + process.emit('exit', 1, null); + const second = capture(); + await pause(); + process.stdout.write(CRASHED_RUN); + await pause(); + process.emit('close', 1, null); + await second; + }); + }); + + const exit = exitResultOf(diagnosticData(log, 'android_snapshot_helper_session_exited')); + assert.equal(exit.exitCode, 1); + assert.equal(exit.shortMsg, 'java.lang.IllegalStateException'); +}); + +test('a session that is stopped while it is still running records no invented cause', async () => { + const { log } = await withLog(async (logPath) => { + const provider = createSessionProvider({ calls: [], processes: [] }); + await inScope(logPath, async () => { + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + await resetAndroidSnapshotHelperSessions(); + }); + }); + + assert.ok(!log.includes('android_snapshot_helper_session_exited')); +}); + +test('the request log entry for a failed capture carries the instrumentation result', async () => { + const { result: caught, log } = await withLog(async (logPath) => { + return await inScope(logPath, async () => { + let thrown: unknown; + await snapshotAndroid(emulator, { + helperAdb: helperFailingWith({ exitCode: 1, stdout: ABORTED_RUN }), + helperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }).catch((error: unknown) => { + thrown = error; + }); + return thrown as AppError; + }); + }); + + assert.equal(exitResultOf(caught.details ?? {}).aborted, 'System has crashed.'); + const logged = exitResultOf(diagnosticData(log, 'android_snapshot_helper_failed')); + assert.equal(logged.exitCode, 1); + assert.equal(logged.aborted, 'System has crashed.'); +}); + +test('a malformed helper report names its size, never the snapshot text it held', () => { + const stdout = [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: outputFormat=uiautomator-xml', + 'INSTRUMENTATION_STATUS: chunkIndex=0', + 'INSTRUMENTATION_STATUS: chunkCount=1', + `INSTRUMENTATION_STATUS: payloadBase64=${Buffer.from(``).toString('base64')}`, + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: ok=true', + ].join('\n'); + + assert.throws( + () => parseAndroidSnapshotHelperOutput(stdout), + (error: unknown) => { + assertPrivate(JSON.stringify(normalizeError(error))); + return error instanceof AppError && typeof error.details?.xmlLength === 'number'; + }, + ); +}); + +test('a ready timeout in the middle of a record leaves the final fields intact', async () => { + const child = new FakeAndroidProcess(); + const record = recordInstrumentationProcess(child); + const failure = rejection(waitForAndroidSnapshotHelperSessionReady(child, 30, undefined, record)); + child.stdout.write('INSTRUMENTATION_RESULT: longMsg=UiAutomation fai'); + const timedOut = await failure; + + assert.equal(exitResultOf(timedOut.details ?? {}).longMsg, undefined); + + child.stdout.write('led\nCaused by: binder has died\nINSTRUMENTATION_CODE: 0\n'); + await pause(); + child.emitExit(1, null); + await record.closed; + const final = record.exitResult(); + assert.equal(final?.longMsg, 'UiAutomation failed\nCaused by: binder has died'); + assert.equal(final?.resultCode, 0); +}); + +test('startup failures are read from stderr only through a strict whole-line envelope', async () => { + const component = 'com.example/.Missing$Inner'; + for (const line of [ + `android.util.AndroidException: INSTRUMENTATION_FAILED: ${component}`, + `onError: commandError=true message=INSTRUMENTATION_FAILED: ${component}`, + ]) { + const result = await captureExitResult({ exitCode: 1, stdout: '', stderr: `${line}\n` }); + assert.equal(result.failed, component, line); + } + for (const line of [ + `android.util.AndroidException: INSTRUMENTATION_FAILED: `, + ` { + const script = + 'process.stdout.write(process.env.PRINTED_STDOUT); process.stderr.write(process.env.PRINTED_STDERR); setTimeout(() => {}, 10_000);'; + const env = { + ...process.env, + PRINTED_STDOUT: `${encodedPayload(SECRET_XML)}\nINSTRUMENTATION_STATUS_CODE: 1\n`, + PRINTED_STDERR: `ret" text="${SECRET}" />\n`, + }; + let caught: AppError | undefined; + try { + await captureAndroidSnapshotWithHelper({ + adb: async () => await runCmd(process.execPath, ['-e', script], { timeoutMs: 600, env }), + }); + } catch (error) { + caught = error as AppError; + } + + assert.ok(caught instanceof AppError); + const wire = normalizeError(caught); + assertPrivate(JSON.stringify({ ...wire.details, stderr: undefined })); + assert.equal('stdout' in (caught.details ?? {}), false); + assert.ok(typeof caught.details?.stderr === 'string'); + assert.equal(caught.details?.timeoutMs, 600); + assert.equal(exitResultOf(caught.details ?? {}).statusCode, 1); +}); + +test('a session that exits while its streams stay open still fails startup promptly', async () => { + const child = new FakeAndroidProcess(); + const controller = new AbortController(); + const failure = rejection( + waitForAndroidSnapshotHelperSessionReady( + child, + 5_000, + controller.signal, + recordInstrumentationProcess(child), + ), + ); + child.stdout.write('INSTRUMENTATION_ABORTED: System has crashed.\n'); + await pause(); + child.exitCode = 1; + child.emit('exit', 1, null); + + const startedAt = Date.now(); + const error = await failure; + + assert.ok(Date.now() - startedAt < 2_000); + assert.equal(exitResultOf(error.details ?? {}).exitCode, 1); + assert.equal(exitResultOf(error.details ?? {}).aborted, 'System has crashed.'); +}); + +test('a failed live session is retired without waiting on a timer for an exit', async () => { + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls: [], processes, responseMode: 'malformed' }); + + // With timers frozen, any retirement step that waits on a timer for exit evidence never ends. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5556', + }); + assert.equal(output, undefined); + } finally { + vi.useRealTimers(); + } + assert.equal(processes[0]?.killed, true); +}); diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 2b170e4522..07ea27d741 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -32,6 +32,8 @@ export type AndroidAdbExecutorResult = { stdout: string; stderr: string; stdoutBuffer?: Buffer; + /** The signal that ended the process, when one did. */ + signal?: string; }; /** Structural mirror of node's StdioOptions; R13 bars the child_process import that names it. */ diff --git a/packages/platform-android/src/instrumentation-helper.ts b/packages/platform-android/src/instrumentation-helper.ts index daaf07a5df..33c59867f2 100644 --- a/packages/platform-android/src/instrumentation-helper.ts +++ b/packages/platform-android/src/instrumentation-helper.ts @@ -1,3 +1,4 @@ +import type { AndroidAdbProcess } from './adb-executor.ts'; // Shared primitives for the Android instrumentation helpers (snapshot + multi-touch). // Both helpers drive `am instrument -w` and parse the resulting // INSTRUMENTATION_STATUS / INSTRUMENTATION_RESULT key/value records, and both @@ -115,6 +116,255 @@ export function readInstrumentationResultBoolean(value: string | undefined): boo return undefined; } +/** + * What an `am instrument` run that returned no usable report says about its own end. + * + * Private by construction: no line of either stream is copied. The fields below are parsed from + * stdout protocol records, plus the startup-failure component from a strict whole-line envelope on + * either stream. A snapshot payload or raw XML the helper printed has no way in. + */ +export type AndroidInstrumentationExitResult = { + /** Host process exit status; `null` when a signal ended it. */ + exitCode: number | null; + signal?: string; + /** `INSTRUMENTATION_CODE`. */ + resultCode?: number; + /** The last `INSTRUMENTATION_STATUS_CODE`. */ + statusCode?: number; + /** `INSTRUMENTATION_ABORTED`: the system ended the run before it reported. */ + aborted?: string; + /** The component of `INSTRUMENTATION_FAILED`: the run never started. */ + failed?: string; + shortMsg?: string; + longMsg?: string; + error?: string; + stack?: string; + /** + * Fields dropped whole because their value looked like snapshot content (markup, an + * attribute-like assignment, a payload record) or because a line of it was cut at the line limit. + */ + withheldFields?: Array; +}; + +const LINE_MAX_CHARS = 4_096; +const MESSAGE_FIELD_MAX_CHARS = 2_048; +// A startup failure is read only from a whole line of one of the three shapes AOSP prints; a +// component name has no space, quote or angle bracket, so a line holding any other text cannot match. +const COMPONENT = '([A-Za-z0-9_.$/]*)'; +const FAILED_ENVELOPES = [ + new RegExp(`^INSTRUMENTATION_FAILED: ?${COMPONENT}$`), + new RegExp(`^android\\.util\\.AndroidException: INSTRUMENTATION_FAILED: ${COMPONENT}$`), + new RegExp(`^onError: commandError=(?:true|false) message=INSTRUMENTATION_FAILED: ${COMPONENT}$`), +]; +// A message is withheld whole, with no list of what the snapshot XML can contain: any +// attribute-like assignment, any markup or entity, or a payload record. The accepted cost is that a +// crash message with an ordinary `key="value"` is withheld too; `withheldFields` still says it existed. +const ATTRIBUTE_LIKE_RE = /[A-Za-z][\w:-]*\s*=\s*["']/; +const MARKUP_RE = /[<>]|&[A-Za-z][A-Za-z0-9]*;|&#|payloadBase64/i; + +/** Only the JVM tokens `` and `` are exempt from the markup rule. */ +function holdsSnapshotMarker(text: string): boolean { + return ( + ATTRIBUTE_LIKE_RE.test(text) || + MARKUP_RE.test(text.replaceAll('', '').replaceAll('', '')) + ); +} + +const ABORTED_RE = /^INSTRUMENTATION_ABORTED:[ \t]?(.*)$/; +const PROTOCOL_RECORD_RE = /^INSTRUMENTATION_(?:STATUS|STATUS_CODE|RESULT|CODE|ABORTED|FAILED):/; +const VALUE_RECORD_RE = /^INSTRUMENTATION_(?:STATUS|RESULT): ([^=]*)=(.*)$/; +const CODE_RE = /^INSTRUMENTATION_(STATUS_CODE|CODE): (-?\d+)\s*$/; +// The protocol keys whose values are text, by the field they fill. +const MESSAGE_FIELDS = { shortMsg: 'shortMsg', longMsg: 'longMsg', Error: 'error', stack: 'stack' }; +type MessageField = 'shortMsg' | 'longMsg' | 'error' | 'stack'; + +type LineAssembler = { write(chunk: string): void; end(): void }; + +/** Splits one stream into lines; a line longer than `LINE_MAX_CHARS` keeps its head and is reported as cut. */ +function createLineAssembler(onLine: (line: string, cut: boolean) => void): LineAssembler { + let pending = ''; + let cut = false; + const append = (text: string) => { + pending += text; + if (pending.length <= LINE_MAX_CHARS) return; + cut = true; + pending = pending.slice(0, LINE_MAX_CHARS); + }; + const flush = () => { + onLine(pending.replace(/\r$/, ''), cut); + pending = ''; + cut = false; + }; + return { + write(chunk) { + let rest = chunk; + for (let newline = rest.indexOf('\n'); newline >= 0; newline = rest.indexOf('\n')) { + append(rest.slice(0, newline)); + flush(); + rest = rest.slice(newline + 1); + } + append(rest); + }, + end() { + if (pending) flush(); + }, + }; +} + +/** One run's output, read as two streams with a parser state each. */ +function createInstrumentationOutput() { + const fields: Partial< + Record & + Record<'resultCode' | 'statusCode', number> + > = {}; + let open: MessageField | undefined; + const withheld = new Set(); + /** A line the limit cut cannot be proven clean, so its field is withheld like a marked one. */ + const checked = (field: MessageField | 'aborted', text: string, cut: boolean) => { + if (cut || holdsSnapshotMarker(text)) withheld.add(field); + return text; + }; + const readFailed = (line: string, cut: boolean): boolean => { + if (cut) return false; + for (const envelope of FAILED_ENVELOPES) { + const match = envelope.exec(line); + if (match) { + fields.failed ??= match[1] ?? ''; + return true; + } + } + return false; + }; + const readStdoutLine = (line: string, cut: boolean) => { + if (readFailed(line, cut)) { + open = undefined; + } else if (PROTOCOL_RECORD_RE.test(line)) { + open = readProtocolRecord(line, cut); + } else if (open) { + fields[open] = appendLine(fields[open], checked(open, line, cut)); + } + }; + /** Reads one protocol record; answers the message field whose value it opens, if any. */ + const readProtocolRecord = (line: string, cut: boolean): MessageField | undefined => { + const code = CODE_RE.exec(line); + if (code) { + const value = Number(code[2]); + if (!Number.isSafeInteger(value)) return undefined; + if (code[1] === 'CODE') fields.resultCode ??= value; + else fields.statusCode = value; + return undefined; + } + const aborted = ABORTED_RE.exec(line); + if (aborted) { + if (fields.aborted !== undefined) return undefined; + // The abort message is one line; whatever follows it is not part of it. + fields.aborted = checked('aborted', aborted[1] ?? '', cut).slice(0, MESSAGE_FIELD_MAX_CHARS); + return undefined; + } + const record = VALUE_RECORD_RE.exec(line); + const field = record ? messageFieldOf(record[1]!) : undefined; + if (!field || fields[field] !== undefined) return undefined; + fields[field] = checked(field, record![2] ?? '', cut).slice(0, MESSAGE_FIELD_MAX_CHARS); + return field; + }; + const stdout = createLineAssembler(readStdoutLine); + const stderr = createLineAssembler(readFailed); + return { + writeStdout: stdout.write, + writeStderr: stderr.write, + /** Finalizing reads the line still being written; a snapshot of a live process does not. */ + result( + exitCode: number | null | undefined, + signal: string | null | undefined, + final: boolean, + ): AndroidInstrumentationExitResult { + if (final) { + stdout.end(); + stderr.end(); + } + const shown = { ...fields }; + for (const field of withheld) delete shown[field]; + const { aborted, failed, shortMsg, longMsg, error, stack, resultCode, statusCode } = shown; + return { + exitCode: exitCode ?? null, + ...(signal ? { signal } : {}), + ...(resultCode !== undefined ? { resultCode } : {}), + ...(statusCode !== undefined ? { statusCode } : {}), + ...(aborted !== undefined ? { aborted: aborted.trim() } : {}), + ...(failed !== undefined ? { failed } : {}), + ...(shortMsg !== undefined ? { shortMsg: shortMsg.trimEnd() } : {}), + ...(longMsg !== undefined ? { longMsg: longMsg.trimEnd() } : {}), + ...(error !== undefined ? { error: error.trimEnd() } : {}), + ...(stack !== undefined ? { stack: stack.trimEnd() } : {}), + ...(withheld.size > 0 ? { withheldFields: [...withheld].sort() } : {}), + }; + }, + }; +} + +function messageFieldOf(key: string): MessageField | undefined { + return Object.hasOwn(MESSAGE_FIELDS, key) + ? (MESSAGE_FIELDS[key as keyof typeof MESSAGE_FIELDS] as MessageField) + : undefined; +} + +function appendLine(value: string | undefined, line: string): string | undefined { + const next = `${value ?? ''}\n${line}`; + return next.length > MESSAGE_FIELD_MAX_CHARS ? value : next; +} + +export function readInstrumentationExitResult(run: { + stdout: string; + stderr: string; + exitCode: number | null | undefined; + signal?: string | null; +}): AndroidInstrumentationExitResult { + const output = createInstrumentationOutput(); + output.writeStdout(run.stdout); + output.writeStderr(run.stderr); + return output.result(run.exitCode, run.signal, true); +} + +/** The exit of a spawned helper process and what it printed. */ +export type AndroidInstrumentationProcessRecord = { + /** Settles when the process's output streams have closed, the point its output is complete. */ + closed: Promise; + /** The process's exit result once its streams have closed, or `undefined` before that. */ + exitResult(): AndroidInstrumentationExitResult | undefined; + /** + * What the process has printed so far, for a process that is still running. A line still being + * written is left out, and the record keeps reading it. + */ + current(): AndroidInstrumentationExitResult; +}; + +export function recordInstrumentationProcess( + child: AndroidAdbProcess, +): AndroidInstrumentationProcessRecord { + const output = createInstrumentationOutput(); + const listen = (stream: AndroidAdbProcess['stdout'], write: (chunk: string) => void) => { + const decoder = new TextDecoder(); + stream?.on('data', (chunk: Buffer | string) => { + write(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true })); + }); + }; + listen(child.stdout, output.writeStdout); + listen(child.stderr, output.writeStderr); + let isClosed = false; + const closed = new Promise((resolve) => { + child.once('close', () => { + isClosed = true; + resolve(); + }); + }); + return { + closed, + exitResult: () => + isClosed ? output.result(child.exitCode, child.signalCode, true) : undefined, + current: () => output.result(child.exitCode, child.signalCode, false), + }; +} + export { readAndroidHelperManifestInteger, readAndroidHelperManifestLiteral, diff --git a/packages/platform-android/src/snapshot-helper-capture.ts b/packages/platform-android/src/snapshot-helper-capture.ts index a23c9f7f3c..c1402b0186 100644 --- a/packages/platform-android/src/snapshot-helper-capture.ts +++ b/packages/platform-android/src/snapshot-helper-capture.ts @@ -11,6 +11,7 @@ import { readInstrumentationResultBoolean, readInstrumentationResultIntegerList, readInstrumentationResultNumber, + readInstrumentationExitResult, } from './instrumentation-helper.ts'; import { ANDROID_SNAPSHOT_HELPER_CAPTURE_TIMEOUT_MS, @@ -86,7 +87,7 @@ export async function captureAndroidSnapshotWithHelper( }, ); } catch (error) { - if (!options.signal?.aborted) throw error; + if (!options.signal?.aborted) throw withoutRawStreams(error); await retireCanceledAndroidSnapshotHelperCommand({ deviceKey, packageName: resolved.packageName, @@ -94,7 +95,7 @@ export async function captureAndroidSnapshotWithHelper( cause: error, }); options.signal.throwIfAborted(); - throw error; + throw withoutRawStreams(error); } if (options.signal?.aborted) { await retireCanceledAndroidSnapshotHelperCommand({ @@ -107,15 +108,42 @@ export async function captureAndroidSnapshotWithHelper( } const output = readAndroidSnapshotHelperOutput(result); if (result.exitCode !== 0) { + const instrumentation = readInstrumentationExitResult(result); throw new AppError( 'COMMAND_FAILED', 'Android snapshot helper failed', - execFailureDetails(result, { helper: output.metadata }), + execFailureDetails( + { stdout: '', stderr: result.stderr, exitCode: result.exitCode }, + { helper: output.metadata, instrumentation }, + ), ); } return output; } +/** + * An executor failure (a deadline, a spawn error) carries the helper's raw stdout, which can hold the + * snapshot it was printing. It is replaced by the parsed instrumentation result; `stderr` and the + * typed failure metadata stay. + */ +function withoutRawStreams(error: unknown): unknown { + if (!(error instanceof AppError) || error.details?.stdout === undefined) return error; + const { stdout, ...details } = error.details; + return new AppError( + error.code, + error.message, + { + ...details, + instrumentation: readInstrumentationExitResult({ + stdout: typeof stdout === 'string' ? stdout : '', + stderr: typeof details.stderr === 'string' ? details.stderr : '', + exitCode: typeof details.exitCode === 'number' ? details.exitCode : null, + }), + }, + error.cause, + ); +} + /** * The single construction path for a helper call's capture options. * @@ -216,21 +244,30 @@ function readAndroidSnapshotHelperOutput( // The helper can report structured ok=false details even when am exits non-zero. return parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`); } catch (error) { - const helperFailure = error instanceof AppError && error.details?.helper ? error : undefined; // `am instrument` exits 0 after a helper that reported its own failure, so that report is the - // answer. - if (helperFailure) throw helperFailure; + // answer: its message, metadata and typed reason stay, and the instrumentation result is added. + if (error instanceof AppError && error.details?.helper) { + throw new AppError( + error.code, + error.message, + { ...error.details, instrumentation: readInstrumentationExitResult(result) }, + error.cause, + ); + } // exec-guard-allow: reachable at exit 0 (helper output unparseable); the // message already branches on the exit code. + // Stdout is not copied into the details: it can hold the snapshot the helper was printing. + // The new `instrumentation` detail is parsed fields only; `stderr` stays as it always was. + const instrumentation = readInstrumentationExitResult(result); throw new AppError( 'COMMAND_FAILED', result.exitCode === 0 ? 'Android snapshot helper output could not be parsed' : 'Android snapshot helper failed before returning parseable output', { - stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, + instrumentation, ...androidCaptureFailureReasonDetail( androidCaptureFailureReasonFromExitCode(result.exitCode), ), @@ -311,7 +348,7 @@ function decodeHelperXml( ).toString('utf8'); if (!xml.includes('')) { throw new AppError('COMMAND_FAILED', 'Android snapshot helper output did not contain XML', { - xml, + xmlLength: xml.length, }); } return xml; diff --git a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts index 72be1819e7..d1eb481cec 100644 --- a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts +++ b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts @@ -7,6 +7,10 @@ * `snapshot-helper-session.ts`; they acquire through here and never reach the registry themselves. */ import type { AndroidAdbProcess } from './adb-executor.ts'; +import { + recordInstrumentationProcess, + type AndroidInstrumentationProcessRecord, +} from './instrumentation-helper.ts'; import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import { requireAndroidAdbHost } from './adb-host.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -84,6 +88,8 @@ export type AndroidSnapshotHelperSession = { port: number; adb: AndroidAdbExecutor; process: AndroidAdbProcess; + /** What the helper process printed and how it ended, for a session that dies on its own. */ + processRecord: AndroidInstrumentationProcessRecord; startedAtMs: number; capturedCount: number; commands: ReadonlySet; @@ -212,6 +218,7 @@ async function tryStartAndroidSnapshotHelperSession(params: { data: { deviceKey: params.deviceKey, reason: error instanceof AppError ? error.details?.reason : undefined, + instrumentation: error instanceof AppError ? error.details?.instrumentation : undefined, detail: error instanceof Error ? error.message : String(error), }, }); @@ -303,6 +310,7 @@ async function startAndroidSnapshotHelperSession(params: { allowFailure: true, captureOutput: false, }); + const processRecord = recordInstrumentationProcess(childProcess); const session: AndroidSnapshotHelperSession = { identity: params.identity, deviceKey: params.deviceKey, @@ -316,6 +324,7 @@ async function startAndroidSnapshotHelperSession(params: { port, adb: params.options.adb, process: childProcess, + processRecord, startedAtMs: Date.now(), capturedCount: 0, commands: new Set(), @@ -329,6 +338,7 @@ async function startAndroidSnapshotHelperSession(params: { childProcess, params.startBudgetMs, params.options.signal, + processRecord, ); sessions().set(params.deviceKey, session); failedSessions().delete(params.identity); @@ -463,6 +473,7 @@ export async function stopAndroidSnapshotHelperSession( const session = sessions().get(deviceKey); if (!session) return false; sessions().delete(deviceKey); + await reportSessionExitedOnItsOwn(deviceKey, session); const processExit = observeAndroidSnapshotHelperProcessExit(session.process); const force = options.force === true || options.signal?.aborted === true; const graceful = await requestGracefulSessionExit(session, processExit, force, options.signal); @@ -533,6 +544,32 @@ export async function stopAndroidSnapshotHelperSession( return true; } +/** + * Records the exit result of a session whose helper process ended before this stop. Read before + * teardown, so a process this stop ends has no cause of its own to report; a process that exited may + * still be flushing its last output, so its streams get a short drain. A running process costs + * nothing here. + */ +async function reportSessionExitedOnItsOwn( + deviceKey: string, + session: AndroidSnapshotHelperSession, +): Promise { + if (!hasAndroidSnapshotHelperProcessEnded(session.process)) return; + await waitForAndroidSnapshotHelperProcessExit( + session.processRecord.closed, + ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS, + ); + emitDiagnostic({ + level: 'warn', + phase: 'android_snapshot_helper_session_exited', + data: { + deviceKey, + capturedCount: session.capturedCount, + instrumentation: session.processRecord.exitResult() ?? session.processRecord.current(), + }, + }); +} + async function requestGracefulSessionExit( session: AndroidSnapshotHelperSession, processExit: AndroidSnapshotHelperProcessExit, diff --git a/packages/platform-android/src/snapshot-helper-session-protocol.ts b/packages/platform-android/src/snapshot-helper-session-protocol.ts index b9d031d476..4f27615591 100644 --- a/packages/platform-android/src/snapshot-helper-session-protocol.ts +++ b/packages/platform-android/src/snapshot-helper-session-protocol.ts @@ -5,6 +5,7 @@ import { androidCaptureFailureReasonFromHelperResult, } from './snapshot-capture-failure-reason.ts'; import { + type AndroidInstrumentationProcessRecord, readInstrumentationResultBoolean, readInstrumentationResultIntegerList, readInstrumentationResultNumber, @@ -111,10 +112,24 @@ export async function requestAndroidSnapshotHelperSessionSnapshot(params: { const SESSION_READY_TIMEOUT_REASON = 'android_snapshot_helper_session_ready_timeout'; const SESSION_EXITED_BEFORE_READY_REASON = 'android_snapshot_helper_session_exited_before_ready'; +const SESSION_EXIT_DRAIN_MS = 250; + +/** Resolves when the process's streams have closed, or after the drain budget, whichever is first. */ +function settleAfterDrain(record: AndroidInstrumentationProcessRecord): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, SESSION_EXIT_DRAIN_MS); + void record.closed.then(() => { + clearTimeout(timer); + resolve(); + }); + }); +} + export function waitForAndroidSnapshotHelperSessionReady( childProcess: AndroidAdbProcess, timeoutMs: number, - signal?: AbortSignal, + signal: AbortSignal | undefined, + record: AndroidInstrumentationProcessRecord, ): Promise> { return new Promise((resolve, reject) => { let output = ''; @@ -122,7 +137,7 @@ export function waitForAndroidSnapshotHelperSessionReady( signal?.removeEventListener('abort', onAbort); reject( new AppError('COMMAND_FAILED', 'Android snapshot helper session did not become ready', { - output, + instrumentation: record.current(), timeoutMs, reason: SESSION_READY_TIMEOUT_REASON, }), @@ -154,15 +169,19 @@ export function waitForAndroidSnapshotHelperSessionReady( childProcess.stderr?.on('data', onData); childProcess.once('exit', (code, exitSignal) => { clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); - reject( - new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', { - output, - exitCode: code, - signal: exitSignal, - reason: SESSION_EXITED_BEFORE_READY_REASON, - }), - ); + // The exit result waits for the streams to drain, but not for an adb whose forked server + // keeps them open; cancellation stays armed until the promise settles. + void settleAfterDrain(record).then(() => { + signal?.removeEventListener('abort', onAbort); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', { + exitCode: code, + signal: exitSignal, + instrumentation: record.exitResult() ?? record.current(), + reason: SESSION_EXITED_BEFORE_READY_REASON, + }), + ); + }); }); childProcess.on('error', (error) => { clearTimeout(timer); @@ -223,7 +242,7 @@ export function parseAndroidSnapshotHelperSessionSnapshotResponse( throw new AppError( 'COMMAND_FAILED', 'Android snapshot helper session returned malformed output', - { response }, + { responseLength: response.length }, ); } const headers = parseHeaders(response.slice(0, separator)); @@ -277,7 +296,7 @@ function validateSnapshotXml(headers: Record, xml: string): void if (!xml.includes('')) { throw new AppError('COMMAND_FAILED', 'Android snapshot helper session did not return XML', { headers, - xml, + xmlLength: xml.length, }); } } diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 3ab0bdd6f2..c106b9267d 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -68,6 +68,9 @@ async function captureFromAndroidSnapshotHelperSession(params: { }, }; } catch (error) { + // Read before the retirement below, which ends the process: a process that is still running + // has no cause of its own to report, and the retirement never waits for one. + const instrumentation = session.processRecord.current(); await retireFailedAndroidSnapshotHelperSession({ deviceKey, identity: session.identity, @@ -83,6 +86,7 @@ async function captureFromAndroidSnapshotHelperSession(params: { data: { deviceKey, reason: error instanceof Error ? error.message : String(error), + instrumentation, // The typed reason the session protocol already published, not a second // comparison against the helper's error type: one owner, one taxonomy (#1983). uiAutomationConnectionTimeout: diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 923c23c14a..baa411529e 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -688,7 +688,10 @@ async function rejectAndroidHelperCaptureFailure(params: { emitDiagnostic({ level: 'error', phase: 'android_snapshot_helper_failed', - data: { reason: failureReason }, + data: { + reason: failureReason, + instrumentation: normalizeError(params.error).details?.instrumentation, + }, }); await stopAndroidSnapshotHelperSession(params.helperDeviceKey); await resetAndroidSnapshotHelperRuntime(params.adb, params.artifact.manifest.packageName);