Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ test('perf preserves successful metrics and normalizes per-metric Android sampli
expect(memory?.available).toBe(false);
expect(memory?.reason).toBe('error: device offline');
expect(memory?.error?.code).toBe('COMMAND_FAILED');
expect(memory?.error?.hint).toMatch(/retry with --debug/i);
expect(memory?.error?.hint).toMatch(/adb reconnect/i);
expect(memory?.error?.details?.metric).toBe('memory');
expect(memory?.error?.details?.package).toBe('com.example.app');
expect(cpu?.available).toBe(true);
Expand Down
4 changes: 3 additions & 1 deletion src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,9 @@ function enrichDaemonError(command: string, error: DaemonError): DaemonError {
? supportedPlatformsForCommand(command)
: [];
const supportedOn = supportedPlatforms.length > 0 ? supportedPlatforms.join(', ') : undefined;
const retriable = retriableForErrorCode(error.code);
// A throw-site classification (lifted from details by normalizeError) wins
// over the conservative code-level policy.
const retriable = error.retriable ?? retriableForErrorCode(error.code);
if (supportedOn === undefined && retriable === undefined) return error;
return {
...error,
Expand Down
11 changes: 11 additions & 0 deletions src/kernel/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type AppErrorDetails = Record<string, unknown> & {
hint?: string;
diagnosticId?: string;
logPath?: string;
retriable?: boolean;
};

export type NormalizedError = {
Expand All @@ -42,6 +43,12 @@ export type NormalizedError = {
hint?: string;
diagnosticId?: string;
logPath?: string;
/**
* Lifted from `details.retriable` when a throw site classified the failure as
* clearly transient (or clearly not). Included only when set, so the default
* error wire shape is unchanged.
*/
retriable?: boolean;
details?: Record<string, unknown>;
};

Expand Down Expand Up @@ -91,6 +98,8 @@ export function normalizeError(
(details && typeof details.logPath === 'string' ? details.logPath : undefined) ??
context.logPath;
const hint = detailHint ?? defaultHintForCode(appErr.code);
const retriable =
details && typeof details.retriable === 'boolean' ? details.retriable : undefined;
const cleanDetails = stripDiagnosticMeta(details);
const message = maybeEnrichCommandFailedMessage(appErr.code, appErr.message, details);

Expand All @@ -100,6 +109,7 @@ export function normalizeError(
hint,
diagnosticId,
logPath,
...(retriable !== undefined ? { retriable } : {}),
details: cleanDetails,
};
}
Expand Down Expand Up @@ -148,6 +158,7 @@ function stripDiagnosticMeta(
delete output.hint;
delete output.diagnosticId;
delete output.logPath;
delete output.retriable;
return Object.keys(output).length > 0 ? output : undefined;
}

Expand Down
297 changes: 297 additions & 0 deletions src/platforms/android/__tests__/adb-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => {
});

import {
androidAdbResultError,
attachAdbFailureHint,
classifyAdbFailure,
createAndroidPortReverseManager,
createDeviceAdbExecutor,
createLocalAndroidAdbProvider,
Expand All @@ -28,6 +31,7 @@ import {
withAndroidAdbProvider,
} from '../adb-executor.ts';
import { runCmd, runCmdBackground } from '../../../utils/exec.ts';
import { AppError, normalizeError } from '../../../kernel/errors.ts';

const mockRunCmd = vi.mocked(runCmd);
const mockRunCmdBackground = vi.mocked(runCmdBackground);
Expand Down Expand Up @@ -305,3 +309,296 @@ test('explicit transfer helpers keep exec-shaped fallback for older providers',
['install', '-r', '/app.apk'],
]);
});

test('classifyAdbFailure recognizes the common adb failure families', () => {
const cases: Array<[stderr: string, reason: string, retriable: boolean | undefined]> = [
['adb: device offline', 'device_offline', true],
[
"error: device unauthorized.\nThis adb server's $ADB_VENDOR_KEYS is not set",
'device_unauthorized',
undefined,
],
['adb: more than one device/emulator', 'multiple_devices', undefined],
['error: more than one device and emulator', 'multiple_devices', undefined],
['error: no devices/emulators found', 'no_devices', undefined],
["error: device 'emulator-5554' not found", 'device_not_found', true],
['error: device not found', 'device_not_found', true],
[
"adb server version (40) doesn't match this client (41); killing...",
'server_version_mismatch',
true,
],
[
"error: protocol fault (couldn't read status): Connection reset by peer",
'connection_dropped',
true,
],
['error: transport error', 'connection_dropped', true],
[
'adb: failed to install app.apk: Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]',
'install_insufficient_storage',
undefined,
],
[
'adb: failed to install app.apk: Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match]',
'install_update_incompatible',
undefined,
],
[
'adb: failed to install app.apk: Failure [INSTALL_FAILED_VERSION_DOWNGRADE]',
'install_version_downgrade',
undefined,
],
[
'adb: failed to install app.apk: Failure [INSTALL_FAILED_NO_MATCHING_ABIS]',
'install_failed',
undefined,
],
];
for (const [stderr, reason, retriable] of cases) {
const classification = classifyAdbFailure(stderr);
assert.equal(classification?.reason, reason, `reason for: ${stderr}`);
assert.equal(classification?.retriable, retriable, `retriable for: ${stderr}`);
assert.ok((classification?.hint ?? '').length > 0, `hint for: ${stderr}`);
}
});

test('classifyAdbFailure matches install verdicts on stdout but transport families only on stderr', () => {
const installFromStdout = classifyAdbFailure('', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]');
assert.equal(installFromStdout?.reason, 'install_update_incompatible');
// Arbitrary `adb shell` stdout (e.g. cat-ing a log) must not read as a transport failure.
assert.equal(classifyAdbFailure('', 'log line: device offline detected'), undefined);
assert.equal(classifyAdbFailure('unrelated failure output'), undefined);
});

test('the local adb executor attaches classified hints to thrown command failures', async () => {
mockRunCmd.mockClear();
mockRunCmd.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'adb exited with code 1', {
cmd: 'adb',
exitCode: 1,
stdout: '',
stderr: "error: device unauthorized.\nThis adb server's $ADB_VENDOR_KEYS is not set",
processExitError: true,
}),
);
const adb = createDeviceAdbExecutor({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
});

const error = await adb(['shell', 'echo', 'hi']).then(
() => assert.fail('expected the adb call to reject'),
(err: unknown) => err,
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.adbFailure, 'device_unauthorized');
assert.match(String(error.details?.hint), /authorization prompt/i);
assert.equal(Object.hasOwn(error.details ?? {}, 'retriable'), false);
});

test('the local adb executor flags transient transport failures retriable', async () => {
mockRunCmd.mockClear();
mockRunCmd.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'adb exited with code 1', {
exitCode: 1,
stdout: '',
stderr: 'adb: device offline',
processExitError: true,
}),
);
const adb = createDeviceAdbExecutor({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
});

const error = await adb(['shell', 'echo', 'hi']).then(
() => assert.fail('expected the adb call to reject'),
(err: unknown) => err,
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.adbFailure, 'device_offline');
assert.equal(error.details?.retriable, true);
});

test('attachAdbFailureHint preserves existing hints and ignores non-adb errors', () => {
const withHint = new AppError('COMMAND_FAILED', 'adb exited with code 1', {
stderr: 'adb: device offline',
hint: 'site-specific hint',
});
attachAdbFailureHint(withHint);
assert.equal(withHint.details?.hint, 'site-specific hint');
assert.equal(withHint.details?.adbFailure, 'device_offline');

const otherCode = new AppError('TOOL_MISSING', 'adb not found in PATH', {
stderr: 'adb: device offline',
});
attachAdbFailureHint(otherCode);
assert.equal(Object.hasOwn(otherCode.details ?? {}, 'hint'), false);

const plain = new Error('boom');
assert.equal(attachAdbFailureHint(plain), plain);
});

test('port reverse removal failures surface as classified AppErrors, not bare Errors', async () => {
const provider = createAndroidPortReverseManager(async (args) => {
if (args[0] === 'reverse' && args[1] === '--remove') {
return { stdout: '', stderr: 'error: device offline', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const error = await provider.remove('tcp:8081').then(
() => assert.fail('expected the removal to reject'),
(err: unknown) => err,
);

assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.adbFailure, 'device_offline');
assert.equal(error.details?.retriable, true);
assert.match(String(error.details?.hint), /reconnect/i);
});

test('androidAdbResultError classifies tolerated nonzero results like thrown executor failures', () => {
const error = androidAdbResultError(
'adb uninstall failed for com.example.app',
{ exitCode: 1, stdout: '', stderr: 'error: device offline' },
{ package: 'com.example.app' },
);

assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.stderr, 'error: device offline');
assert.equal(error.details?.exitCode, 1);
assert.equal(error.details?.package, 'com.example.app');
assert.equal(error.details?.adbFailure, 'device_offline');
assert.equal(error.details?.retriable, true);
assert.match(String(error.details?.hint), /adb reconnect/i);
});

test('androidAdbResultError composes the classified hint with the stderr excerpt enrichment', () => {
const error = androidAdbResultError('adb uninstall failed for com.example.app', {
exitCode: 1,
stdout: '',
stderr: 'error: device offline',
});

// execFailureDetails flags processExitError, so normalizeError suffixes the
// curated message with the stderr excerpt while the classified hint rides along.
assert.equal(error.details?.processExitError, true);
const normalized = normalizeError(error);
assert.equal(
normalized.message,
'adb uninstall failed for com.example.app: error: device offline',
);
assert.match(String(normalized.hint), /adb reconnect/i);
assert.equal(normalized.retriable, true);
});

test('androidAdbResultError leaves semantic exit-0 failures without excerpt enrichment', () => {
const error = androidAdbResultError('Failed to launch com.example.app', {
exitCode: 0,
stdout: 'Error: Activity not started',
stderr: 'Warning: unrelated deprecation notice',
});

assert.equal(Object.hasOwn(error.details ?? {}, 'processExitError'), false);
assert.equal(normalizeError(error).message, 'Failed to launch com.example.app');
});

test('androidAdbResultError keeps a site hint over the classified one', () => {
const error = androidAdbResultError(
'Failed to pull Android heap dump',
{ exitCode: 1, stdout: '', stderr: 'error: device offline' },
{ hint: 'site-specific hint' },
);

assert.equal(error.details?.hint, 'site-specific hint');
assert.equal(error.details?.adbFailure, 'device_offline');
});

test('semantic provider install failures carry classified hints', async () => {
const error = await withAndroidAdbProvider(
{
exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
install: async () => {
throw new AppError('COMMAND_FAILED', 'remote install failed', {
exitCode: 1,
stdout: 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match]',
stderr: '',
});
},
},
{ serial: 'emulator-5554' },
async () =>
await installAndroidAdbPackage('/app.apk', { replace: true }).then(
() => assert.fail('expected the provider install to reject'),
(err: unknown) => err,
),
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.adbFailure, 'install_update_incompatible');
assert.match(String(error.details?.hint), /incompatible signature/i);
});

test('explicitly passed provider pull failures carry classified hints', async () => {
const error = await pullAndroidAdbFile('/remote.mp4', '/local.mp4', {
provider: {
exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
pull: async () => {
throw new AppError('COMMAND_FAILED', 'remote pull failed', {
exitCode: 1,
stdout: '',
stderr: 'error: device offline',
});
},
},
}).then(
() => assert.fail('expected the provider pull to reject'),
(err: unknown) => err,
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.adbFailure, 'device_offline');
assert.equal(error.details?.retriable, true);
assert.match(String(error.details?.hint), /adb reconnect/i);
});

test('provider-scoped adb failures get the same classified hints as local execution', async () => {
const device = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
} as const;

const error = await withAndroidAdbProvider(
async () => {
throw new AppError('COMMAND_FAILED', 'remote adb exited with code 1', {
exitCode: 1,
stdout: '',
stderr: 'adb: more than one device/emulator',
});
},
{ serial: 'emulator-5554' },
async () =>
await resolveAndroidAdbExecutor(device)(['shell', 'echo', 'hi']).then(
() => assert.fail('expected the provider-scoped call to reject'),
(err: unknown) => err,
),
);

assert.ok(error instanceof AppError);
assert.equal(error.details?.adbFailure, 'multiple_devices');
assert.match(String(error.details?.hint), /--serial/);
});
Loading
Loading