mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
49e020bafd
## What
A running Vega VVD registers a second adb transport (`emulator-<port>`),
so it can surface in `list-devices` **twice** — once as
`platform:"vega"` and once as `platform:"android"`. There's dedup logic
(`resolveVvdShadowAdbSerials` + `filterVvdShadowsFromAndroid`) that
drops the adb "shadow" row by matching the running VVD's console port —
read from the process table via `ps` — against the adb serial's port.
That dedup was spawning a **bare `"ps"`**, which relies on `PATH`.
## Why it broke
An MCP server is frequently launched from a GUI / launchd context that
inherits a **sanitized `PATH`** omitting `/bin`. There, `execFile("ps",
…)` fails with `ENOENT`; the surrounding `catch` returns an empty port
set, and the dedup silently no-ops — so the VVD shows up as two devices.
It works fine from an interactive shell (where `/bin` is on `PATH`),
which is why it doesn't reproduce in a terminal or in the existing
tests.
Observed: `list-devices` returned both a phantom `platform:"android"`
`emulator-5554` and the real `platform:"vega"` device for a single
running VVD.
## Fix
Pin `ps` to its canonical absolute location, matching how the codebase
already hardcodes `/bin/sh` (`android-binary.ts`, `vega-cli.ts`,
`check-deps.ts`):
```ts
export const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? "ps";
```
Applied to all four `ps` spawn sites:
- `vega-process.ts` — `listRunningVvdConsolePorts`, `listRunningVvdPids`
- `vega-cli.ts` — `collectDescendantPids`, `pgidMembers` (same latent
PATH fragility in the process-reaping paths)
The bare-`"ps"` fallback is retained only if neither canonical path
exists, preserving `PATH` resolution for an atypical layout.
## Tests
- New regression test in `vega-process.test.ts` that strips `/bin` and
`/usr/bin` from `PATH` and asserts the probe still spawns `ps` (a bare
`"ps"` ENOENTs there — verified the test fails against the pre-fix
code).
- Updated the `list-devices.test.ts` mock to match `ps` by basename,
since production now passes an absolute path.
- `npm run build`, typecheck, lint (`--max-warnings 0`), and the full
vega/list-devices suite (47 tests) all pass.
## Verification
End-to-end `list-devices.execute()` under a launchd-style minimal `PATH`
now returns exactly **one** device (the Vega), with no android shadow.
`PS_BIN` resolves to `/bin/ps` and reads the VVD's console port even
with `/bin` removed from `PATH`.
---------
Co-authored-by: Piotr Krawiec <krawipio@amazon.pl>
183 lines
7.6 KiB
TypeScript
183 lines
7.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
parseVvdConsolePorts,
|
|
parseVvdPids,
|
|
listRunningVvdConsolePorts,
|
|
listRunningVvdPids,
|
|
PS_ARGS,
|
|
PS_ARGS_WITH_PID,
|
|
PS_BIN,
|
|
} from "../src/utils/vega-process";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// A `ps` line for a running VVD: the `vega-virtual-device` binary + `-ports`/`-qmp`.
|
|
function vvdLine(consolePort: number, opts: { ports?: boolean } = {}): string {
|
|
const { ports = true } = opts;
|
|
let line =
|
|
"/Users/me/vega/sdk/vega-sdk/main/0.22.6759/vvd/images/tv/vmtools/agent/qemu/" +
|
|
"darwin-aarch64/vega-virtual-device -avd-arch arm64 -skin tv-remote";
|
|
if (ports) line += ` -ports ${consolePort},${consolePort + 1}`;
|
|
line += " -dns-server auto";
|
|
line += ` -qemu -qmp unix:/tmp/qmp-socket-${consolePort}.sock,server,nowait`;
|
|
return line;
|
|
}
|
|
|
|
// Must NOT yield a port: an Android emulator (has `-ports`, wrong process), a
|
|
// crashpad sibling under the SDK path, and a git ref that names the branch.
|
|
const ANDROID_EMULATOR =
|
|
"/Users/me/Library/Android/sdk/emulator/qemu/darwin-aarch64/qemu-system-aarch64 " +
|
|
"-avd Pixel_7 -ports 5554,5555";
|
|
const CRASHPAD =
|
|
"/Users/me/vega/sdk/vega-sdk/main/0.22.6759/vvd/images/tv/vmtools/agent/crashpad_handler " +
|
|
"--database=/tmp/x --annotation=prod=AndroidEmulator";
|
|
const GIT_REF = "git push --dry-run origin vega-review-work:krawipio/vega-virtual-device";
|
|
|
|
describe("parseVvdConsolePorts", () => {
|
|
it("reads the console port from a running VVD's -ports flag", () => {
|
|
expect([...parseVvdConsolePorts(vvdLine(5554))]).toEqual([5554]);
|
|
});
|
|
|
|
it("ignores a co-running Android emulator, a crashpad sibling, and a branch ref", () => {
|
|
const ps = [ANDROID_EMULATOR, CRASHPAD, GIT_REF, vvdLine(5556)].join("\n");
|
|
// The git ref matches the name gate but has no -ports/-qmp → no phantom port.
|
|
expect([...parseVvdConsolePorts(ps)]).toEqual([5556]);
|
|
});
|
|
|
|
it("returns empty when no VVD process is present", () => {
|
|
const ps = [ANDROID_EMULATOR, CRASHPAD, "/sbin/launchd"].join("\n");
|
|
expect(parseVvdConsolePorts(ps).size).toBe(0);
|
|
});
|
|
|
|
it("reports every distinct console port when multiple VVDs run", () => {
|
|
const ps = [vvdLine(5554), vvdLine(5556)].join("\n");
|
|
expect([...parseVvdConsolePorts(ps)].sort((a, b) => a - b)).toEqual([5554, 5556]);
|
|
});
|
|
|
|
it("falls back to the qmp-socket path when -ports is absent", () => {
|
|
expect([...parseVvdConsolePorts(vvdLine(5558, { ports: false }))]).toEqual([5558]);
|
|
});
|
|
|
|
it("matches the legacy kepler-virtual-device process name", () => {
|
|
const line = vvdLine(5554).replace("vega-virtual-device", "kepler-virtual-device");
|
|
expect([...parseVvdConsolePorts(line)]).toEqual([5554]);
|
|
});
|
|
|
|
it("does not match a `…-virtual-device-wrapper` substring", () => {
|
|
const line = vvdLine(5554).replace("vega-virtual-device", "vega-virtual-device-wrapper");
|
|
expect(parseVvdConsolePorts(line).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
// A `ps -o pid=,command=` line: a leading pid column, then the same argv.
|
|
function vvdPidLine(pid: number, consolePort: number): string {
|
|
return ` ${pid} ${vvdLine(consolePort)}`;
|
|
}
|
|
|
|
describe("parseVvdPids", () => {
|
|
it("reads the pid of a running VVD emulator process", () => {
|
|
expect(parseVvdPids(vvdPidLine(75137, 5554))).toEqual([75137]);
|
|
});
|
|
|
|
it("ignores a co-running Android emulator, a crashpad sibling, and a branch ref", () => {
|
|
const ps = [
|
|
` 4242 ${ANDROID_EMULATOR}`,
|
|
` 4243 ${CRASHPAD}`,
|
|
` 4244 ${GIT_REF}`,
|
|
vvdPidLine(75137, 5556),
|
|
].join("\n");
|
|
expect(parseVvdPids(ps)).toEqual([75137]);
|
|
});
|
|
|
|
it("returns empty when no VVD process is present", () => {
|
|
const ps = [` 1 /sbin/launchd`, ` 4242 ${ANDROID_EMULATOR}`].join("\n");
|
|
expect(parseVvdPids(ps)).toEqual([]);
|
|
});
|
|
|
|
it("reports every pid when multiple VVDs run", () => {
|
|
const ps = [vvdPidLine(75137, 5554), vvdPidLine(80001, 5556)].join("\n");
|
|
expect(parseVvdPids(ps).sort((a, b) => a - b)).toEqual([75137, 80001]);
|
|
});
|
|
|
|
it("matches the legacy kepler-virtual-device process name", () => {
|
|
const line = vvdPidLine(75137, 5554).replace("vega-virtual-device", "kepler-virtual-device");
|
|
expect(parseVvdPids(line)).toEqual([75137]);
|
|
});
|
|
|
|
it("does not match a `…-virtual-device-wrapper` substring", () => {
|
|
const line = vvdPidLine(75137, 5554).replace(
|
|
"vega-virtual-device",
|
|
"vega-virtual-device-wrapper"
|
|
);
|
|
expect(parseVvdPids(line)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// Exercises the REAL `ps` invocation (not mocked) so a flag invalid on the host —
|
|
// e.g. the BSD-only `-x` on the Linux CI runner — fails here, fast, instead of only
|
|
// surfacing in the slow Vega e2e (where the catch would otherwise hide it).
|
|
describe("listRunningVvdConsolePorts (real ps)", () => {
|
|
it("PS_ARGS is accepted by the host `ps` (exit 0, non-empty output)", async () => {
|
|
const { stdout } = await execFileAsync("ps", [...PS_ARGS], { maxBuffer: 16 * 1024 * 1024 });
|
|
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("resolves to a Set of positive console ports without throwing", async () => {
|
|
const ports = await listRunningVvdConsolePorts();
|
|
expect(ports).toBeInstanceOf(Set);
|
|
for (const p of ports) expect(Number.isInteger(p) && p > 0).toBe(true);
|
|
});
|
|
|
|
it("PS_ARGS_WITH_PID is accepted by the host `ps` (exit 0, non-empty output)", async () => {
|
|
const { stdout } = await execFileAsync("ps", [...PS_ARGS_WITH_PID], {
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("resolves to positive integer pids without throwing", async () => {
|
|
const pids = await listRunningVvdPids();
|
|
expect(Array.isArray(pids)).toBe(true);
|
|
for (const p of pids) expect(Number.isInteger(p) && p > 0).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Regression: under an MCP server's sanitized PATH (no /bin), a bare-name `ps`
|
|
// spawn ENOENTs; the catch then yields an empty port set and the VVD adb-shadow
|
|
// dedup no-ops, listing the VVD twice. An absolute `ps` path survives this.
|
|
describe("PS_BIN (survives a sanitized PATH)", () => {
|
|
// Every host CI runs on has /bin/ps or /usr/bin/ps, so PS_BIN resolves absolute
|
|
// here. But production deliberately falls back to bare "ps" when neither exists
|
|
// (Nix, a non-POSIX layout) to preserve PATH resolution — so assert the contract
|
|
// as a disjunction, not a hard "always absolute", and don't fail the intentional
|
|
// fallback path.
|
|
const psBinIsAbsolute = PS_BIN.startsWith("/");
|
|
|
|
it("is either an existing absolute path or the bare-`ps` PATH fallback", () => {
|
|
if (psBinIsAbsolute) expect(existsSync(PS_BIN)).toBe(true);
|
|
else expect(PS_BIN).toBe("ps");
|
|
});
|
|
|
|
// Only meaningful when PS_BIN is absolute: a bare "ps" is exactly what ENOENTs
|
|
// under a sanitized PATH, so asserting it survives there would contradict the
|
|
// fallback's intent. Skip rather than fail on a host without a canonical `ps`.
|
|
it.skipIf(!psBinIsAbsolute)("spawns `ps` with PATH stripped of /bin and /usr/bin", async () => {
|
|
const savedPath = process.env.PATH;
|
|
process.env.PATH = "/nonexistent-dir";
|
|
try {
|
|
// Load-bearing: a bare "ps" would reject with ENOENT here, so a non-empty
|
|
// exit-0 result proves the absolute-path resolution is what survives.
|
|
const { stdout } = await execFileAsync(PS_BIN, [...PS_ARGS], {
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
expect(stdout.split("\n").filter(Boolean).length).toBeGreaterThan(0);
|
|
} finally {
|
|
if (savedPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = savedPath;
|
|
}
|
|
});
|
|
});
|