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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
shows that sheet (hosted out of the app process)." to "This snapshot shows a system web sign-in
sheet presented over the app (hosted out of the app process), not app content". The payment host
says "the system Apple Pay sheet" instead.
- Changed (android): a private adb server no longer absorbs a port its caller named. An adb request
that names a server other than the one its route holds — `-P 5037` in argv, or `serverPort` in the
call's options — is now refused with `managed-device-transport-mismatch` before anything is
dispatched, where previously a `-P` in argv was rewritten onto the route's own port. A caller that
asked for 5037 could read a zero exit as an answer about 5037 after the request had run on 15038.
It reaches `createLocalAndroidAdbProvider(device, { serverPort })`,
`runAndroidHostAdb(invocation, { serverPort })`, and the Limrun runtime dependency's adb calls. A
request that names no server, or names the one the route already holds, runs exactly as before, and
ambient adb is unchanged: with no private server named, the caller's argv is what runs, `-P`
included (#2632).
- Fixed (android): a chunked `record stop` (recordings over 170 s) no longer warns that screenrecord
stopped before record stop at the 180 s limit. Rotation always ends every earlier chunk before
stop, so the warning now fires only when the last chunk's recorder had already exited.
Expand Down
14 changes: 8 additions & 6 deletions packages/platform-android/src/__tests__/adb-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,17 @@ test('createLocalAndroidAdbProvider carries a private server port through every
await provider.pull?.('/sdcard/video.mp4', '/tmp/video.mp4');
await provider.install?.('/tmp/app.apk');

assert.equal(readServerPort(mockRunCmdBackground.mock.calls[0]?.[2]), 15_037);
assert.equal(readServerPortArgv(mockRunCmdBackground.mock.calls[0]?.[1]), 15_037);
assert.equal(mockRunCmd.mock.calls.length, 4);
for (const call of mockRunCmd.mock.calls) assert.equal(readServerPort(call[2]), 15_037);
for (const call of mockRunCmd.mock.calls) assert.equal(readServerPortArgv(call[1]), 15_037);
});

function readServerPort(options: unknown): number | undefined {
if (options === null || typeof options !== 'object') return undefined;
const value = (options as { serverPort?: unknown }).serverPort;
return typeof value === 'number' ? value : undefined;
function readServerPortArgv(args: unknown): number | undefined {
if (!Array.isArray(args)) return undefined;
const index = args.indexOf('-P');
if (index === -1) return undefined;
const value = args[index + 1];
return typeof value === 'string' ? Number(value) : undefined;
}

test('createAndroidPortReverseManager makes duplicate setup idempotent and cleans owner mappings', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,34 @@ import {
withoutCommandExecutorOverride,
} from '@agent-device/host-kit/command';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { lowerAndroidAdbInvocation } from '../../adb-transport.ts';
import { bindAndroidAdbHostStub } from '../../adb-host.fixtures.ts';
import { createAndroidFileHost } from './android-file-host.ts';

export function bindAndroidAdbTestHost() {
return bindAndroidAdbHostStub({
environment: process.env,
files: createAndroidFileHost(),
execSerialAdb: async (serial, args, options) =>
await withoutCommandExecutorOverride(
execAdb: async (invocation, options) => {
const lowered = lowerAndroidAdbInvocation(invocation, options, process.env);
return await withoutCommandExecutorOverride(
async () =>
await runCmd('adb', ['-s', serial, ...args], {
...options,
await runCmd('adb', lowered.args, {
...lowered.options,
detached: process.platform !== 'win32',
}),
),
spawnSerialAdb: (serial, args, options) => {
const background = runCmdBackground('adb', ['-s', serial, ...args], {
...options,
);
},
spawnAdb: (invocation, options) => {
const lowered = lowerAndroidAdbInvocation(invocation, options, process.env);
const background = runCmdBackground('adb', lowered.args, {
...lowered.options,
allowFailure: true,
captureOutput: false,
});
void background.wait.catch(() => {});
return background.child;
},
execHostAdb: async (args, options) =>
await runCmd('adb', args, { ...options, detached: process.platform !== 'win32' }),
withAdbCommandExecutorOverride: withCommandExecutorOverride,
withoutAdbCommandExecutorOverride: withoutCommandExecutorOverride,
coerceAdbResult: coerceExecResult,
Expand Down
32 changes: 18 additions & 14 deletions packages/platform-android/src/adb-executor-host.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { parseAndroidAdbArgv } from './adb-transport.ts';
import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts';
import { runAndroidHostAdb, withAndroidHostAdbTransport } from './adb-executor.ts';

test('a scoped transport intercepts host adb without reaching the injected host', async () => {
let hostCalls = 0;
bindAndroidAdbHostStub({
execHostAdb: async () => {
execAdb: async () => {
hostCalls += 1;
return { stdout: 'host', stderr: '', exitCode: 0 };
},
});

const result = await withAndroidHostAdbTransport(
async (args, options) => {
assert.deepEqual(args, ['devices']);
async (invocation, options) => {
assert.deepEqual(invocation.command, ['devices']);
assert.deepEqual(options, { timeoutMs: 1_234 });
return { stdout: 'transport', stderr: '', exitCode: 0 };
},
async () => await runAndroidHostAdb(['devices'], { timeoutMs: 1_234 }),
async () => await runAndroidHostAdb(parseAndroidAdbArgv(['devices']), { timeoutMs: 1_234 }),
);

assert.equal(result.stdout, 'transport');
Expand All @@ -29,13 +30,13 @@ test('a scoped transport intercepts host adb without reaching the injected host'
test('the local host arm always obtains a result before applying the shared failure contract', async () => {
let receivedOptions: Record<string, unknown> | undefined;
bindAndroidAdbHostStub({
execHostAdb: async (_args, options) => {
execAdb: async (_invocation, options) => {
receivedOptions = options;
return { stdout: '', stderr: 'error: device offline', exitCode: 1 };
},
});

const error = await runAndroidHostAdb(['devices']).then(
const error = await runAndroidHostAdb(parseAndroidAdbArgv(['devices'])).then(
() => assert.fail('expected the host adb call to reject'),
(error: unknown) => error,
);
Expand All @@ -49,10 +50,13 @@ test('the local host arm always obtains a result before applying the shared fail

test('allowFailure returns a nonzero local result unchanged', async () => {
const scripted = { stdout: '', stderr: 'offline', exitCode: 7 };
bindAndroidAdbHostStub({ execHostAdb: async () => scripted });
bindAndroidAdbHostStub({ execAdb: async () => scripted });

assert.deepEqual(
await runAndroidHostAdb(['devices'], { allowFailure: true, timeoutMs: 5_000 }),
await runAndroidHostAdb(parseAndroidAdbArgv(['devices']), {
allowFailure: true,
timeoutMs: 5_000,
}),
scripted,
);
});
Expand All @@ -70,15 +74,15 @@ test('unchecked transport results are normalized at the package boundary', async

const result = await withAndroidHostAdbTransport(
async () => sloppy,
async () => await runAndroidHostAdb(['devices'], { allowFailure: true }),
async () => await runAndroidHostAdb(parseAndroidAdbArgv(['devices']), { allowFailure: true }),
);

assert.deepEqual(result, { stdout: '', stderr: '', exitCode: 1 });
});

test('nested transport scopes are innermost-first and restore on scope exit', async () => {
bindAndroidAdbHostStub({
execHostAdb: async () => ({ stdout: 'host', stderr: '', exitCode: 0 }),
execAdb: async () => ({ stdout: 'host', stderr: '', exitCode: 0 }),
});
const transportFor = (name: string) => async () => ({
stdout: name,
Expand All @@ -87,11 +91,11 @@ test('nested transport scopes are innermost-first and restore on scope exit', as
});

await withAndroidHostAdbTransport(transportFor('outer'), async () => {
assert.equal((await runAndroidHostAdb(['devices'])).stdout, 'outer');
assert.equal((await runAndroidHostAdb(parseAndroidAdbArgv(['devices']))).stdout, 'outer');
await withAndroidHostAdbTransport(transportFor('inner'), async () => {
assert.equal((await runAndroidHostAdb(['devices'])).stdout, 'inner');
assert.equal((await runAndroidHostAdb(parseAndroidAdbArgv(['devices']))).stdout, 'inner');
});
assert.equal((await runAndroidHostAdb(['devices'])).stdout, 'outer');
assert.equal((await runAndroidHostAdb(parseAndroidAdbArgv(['devices']))).stdout, 'outer');
});
assert.equal((await runAndroidHostAdb(['devices'])).stdout, 'host');
assert.equal((await runAndroidHostAdb(parseAndroidAdbArgv(['devices']))).stdout, 'host');
});
11 changes: 4 additions & 7 deletions packages/platform-android/src/adb-host.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,11 @@ export function bindAndroidAdbHostStub(
throw new Error('adb-host stub: writeBytes not stubbed');
},
},
execSerialAdb: async () => {
throw new Error('adb-host stub: execSerialAdb not stubbed');
execAdb: async () => {
throw new Error('adb-host stub: execAdb not stubbed');
},
spawnSerialAdb: () => {
throw new Error('adb-host stub: spawnSerialAdb not stubbed');
},
execHostAdb: async () => {
throw new Error('adb-host stub: execHostAdb not stubbed');
spawnAdb: () => {
throw new Error('adb-host stub: spawnAdb not stubbed');
},
withAdbCommandExecutorOverride: async (_override, fn) => await fn(),
withoutAdbCommandExecutorOverride: async (fn) => await fn(),
Expand Down
40 changes: 17 additions & 23 deletions packages/platform-android/src/adb-host.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { AndroidHelperInstallDecision, AndroidImeHelperArtifact } from './helper-artifacts.ts';
import type {
AndroidAdbInvocation,
AndroidAdbExecutor,
AndroidAdbExecutorOptions,
AndroidAdbExecutorResult,
Expand Down Expand Up @@ -45,26 +46,18 @@ export type AndroidAdbHost = Readonly<{
/** Narrow filesystem authority used by Android helper, SDK, and artifact mechanics. */
files: AndroidAdbFileHost;
/**
* Device-scoped local adb execution for `serial`, escaping any active command-executor
* override (a tunnel-backed provider shelling out to adb must not route back into itself)
* and owning the host-side process-group/teardown semantics.
* Local adb execution for one addressing decision, visible to an installed command-executor
* override so a scoped transport can answer for it. A caller that must not be captured — the
* device-scoped executor a provider would otherwise re-enter — wraps this in
* `withoutAdbCommandExecutorOverride`. The invocation's command is appended verbatim; only
* `target` is lowered into adb global options.
*/
execSerialAdb(
serial: string,
args: string[],
options?: AndroidAdbExecutorOptions,
): Promise<AndroidAdbExecutorResult>;
/** Device-scoped local adb background spawn for `serial`; the host owns stream wiring. */
spawnSerialAdb(
serial: string,
args: string[],
options?: AndroidAdbSpawnOptions,
): AndroidAdbProcess;
/** Host-global adb execution (no serial), e.g. `adb devices`. */
execHostAdb(
args: string[],
execAdb(
invocation: AndroidAdbInvocation,
options?: AndroidAdbExecutorOptions,
): Promise<AndroidAdbExecutorResult>;
/** Local adb background spawn for one addressing decision; the host owns stream wiring. */
spawnAdb(invocation: AndroidAdbInvocation, options?: AndroidAdbSpawnOptions): AndroidAdbProcess;
/** Installs `override` as the host command-executor override for the duration of `fn`. */
withAdbCommandExecutorOverride<T>(
override: AndroidAdbCommandExecutorOverride,
Expand Down Expand Up @@ -109,9 +102,9 @@ export type AndroidAdbHost = Readonly<{

let boundHost: AndroidAdbHost | undefined;

/** Scoped override for host-global and explicitly serial-qualified adb argv. */
/** Scoped override for host-global and explicitly serial-qualified adb invocations. */
export type AndroidAdbHostTransport = (
args: string[],
invocation: AndroidAdbInvocation,
options?: AndroidAdbExecutorOptions,
) => Promise<AndroidAdbExecutorResult>;

Expand All @@ -138,20 +131,21 @@ export function requireAndroidAdbHost(): AndroidAdbHost {
* innermost-first and restore automatically.
*/
export async function runAndroidHostAdb(
args: string[],
invocation: AndroidAdbInvocation,
options?: AndroidAdbExecutorOptions,
): Promise<AndroidAdbExecutorResult> {
const host = requireAndroidAdbHost();
const transport = androidAdbHostTransportScope.getStore();
const result = host.coerceAdbResult(
transport
? await transport(args, options)
: await host.execHostAdb(args, { ...options, allowFailure: true }),
? await transport(invocation, options)
: await host.execAdb(invocation, { ...options, allowFailure: true }),
);
if (!options?.allowFailure && result.exitCode !== 0) {
const { androidAdbResultError } = await import('./adb-failure.ts');
const { serializeAndroidAdbInvocation } = await import('./adb-transport.ts');
throw androidAdbResultError(
`adb ${args.join(' ')} exited with code ${result.exitCode}`,
`adb ${serializeAndroidAdbInvocation(invocation).join(' ')} exited with code ${result.exitCode}`,
result,
);
}
Expand Down
Loading
Loading