Files
Ignacy Łątka 268c017c63 fix(vega): stop a VVD the vega CLI lost track of via the ps probe (#402)
## Problem

`vega virtual-device stop` exits non-zero with **"virtual device not
running"** for any VVD the `vega` CLI is not tracking — and that is
**every VVD argent boots**. `boot-device` starts a VVD via `vega
virtual-device start -t N`, which returns once boot completes (rather
than staying foreground), so the CLI never registers the device. `vega
virtual-device status` then reports `running:false` and `stop` is a
no-op-that-throws.

That error threw straight out of `stopVvd`, so **`boot-device
{force:true}` failed outright and left the VVD running** — even though
argent's *detection* (`isVvdRunning` / `listRunningVvdConsolePorts`)
already deliberately trusts the OS process table over the flaky CLI.

Repro (against a VVD argent booted):
```
boot-device {vvdImage:"tv", force:true}
→ [Tool:boot-device] vega virtual-device stop failed: virtual device not running
     at stopVvd (vega-vvd.ts) → bootVegaImpl (boot-device.ts)
```

## Fix

Make **stop** symmetric with **detection**:
- Tolerate the CLI's stale failure (`vega virtual-device stop` is now
best-effort).
- Terminate any VVD emulator process the `ps` probe still finds —
SIGTERM, then SIGKILL the stragglers, verifying via `isVvdRunning`
between.
- Killing by pid demands a **positive** emulator identity — the VVD
binary name **and** a `-ports`/`-qmp` signal — so a process that merely
mentions a `…/vega-virtual-device` path in its argv (e.g. a git command
on a branch of that name) is never signalled.

Also stopped pointing users at `vega virtual-device stop` for an
argent-booted VVD (the boot-device error message + the `argent-vega`
skill); the reliable restart is `boot-device {force:true}`.

## Verification

- `npx vitest run --root packages/tool-server` → **1430 passed**. New
unit tests cover `parseVvdPids` (identity/safety incl. the git-ref
guard) and `stopVvd` (no-kill on clean stop; kill-without-rethrow when
the CLI refuses; SIGTERM→SIGKILL escalation).
- **Live on a VVD**: reproduced the orphan state (argent boot →
`status:false` while pid alive), then `boot-device force:true` — old pid
gone, a fresh single VVD running, `booted:true`. Previously it threw.
2026-06-26 16:40:27 +02:00

126 lines
5.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// The running VVD's console port / pids come from the process table; mock that source.
const listRunningVvdConsolePorts = vi.fn();
const listRunningVvdPids = vi.fn();
vi.mock("../src/utils/vega-process", () => ({
listRunningVvdConsolePorts: (...a: unknown[]) => listRunningVvdConsolePorts(...a),
listRunningVvdPids: (...a: unknown[]) => listRunningVvdPids(...a),
}));
// stopVvd asks the CLI to stop first; mock it so we can make it succeed or throw.
const runVega = vi.fn();
vi.mock("../src/utils/vega-cli", () => ({
runVega: (...a: unknown[]) => runVega(...a),
}));
// discoverVegaConsolePort also confirms the VVD's `emulator-<port>` is adb-ready;
// mock the `adb devices` call but keep the real parser.
const runAdb = vi.fn();
vi.mock("../src/utils/adb", async () => {
const actual = await vi.importActual<typeof import("../src/utils/adb")>("../src/utils/adb");
return { ...actual, runAdb: (...a: unknown[]) => runAdb(...a) };
});
import { discoverVegaConsolePort, MultipleVegaDevicesError, stopVvd } from "../src/utils/vega-vvd";
function adbDevices(...serials: string[]): { stdout: string; stderr: string } {
return {
stdout: ["List of devices attached", ...serials.map((s) => `${s}\tdevice`)].join("\n") + "\n",
stderr: "",
};
}
beforeEach(() => {
listRunningVvdConsolePorts.mockReset();
listRunningVvdPids.mockReset();
runVega.mockReset();
runAdb.mockReset();
runAdb.mockResolvedValue(adbDevices("emulator-5556")); // adb-ready by default
});
describe("discoverVegaConsolePort", () => {
it("returns the sole running VVD's console port once it is adb-ready", async () => {
listRunningVvdConsolePorts.mockResolvedValue(new Set([5556]));
expect(await discoverVegaConsolePort()).toBe(5556);
});
it("throws an actionable error when no VVD process is running", async () => {
listRunningVvdConsolePorts.mockResolvedValue(new Set());
await expect(discoverVegaConsolePort()).rejects.toThrow(/No running Vega Virtual Device/);
});
it("throws a typed MultipleVegaDevicesError when more than one VVD is running", async () => {
listRunningVvdConsolePorts.mockResolvedValue(new Set([5554, 5556]));
await expect(discoverVegaConsolePort()).rejects.toBeInstanceOf(MultipleVegaDevicesError);
await expect(discoverVegaConsolePort()).rejects.toThrow(
/Multiple Vega Virtual Devices detected/
);
});
it("waits for adb and throws a clear error if the VVD's emulator never registers", async () => {
listRunningVvdConsolePorts.mockResolvedValue(new Set([5556]));
runAdb.mockResolvedValue(adbDevices()); // emulator-5556 absent from `adb devices`
await expect(discoverVegaConsolePort({ adbReadyTimeoutMs: 0 })).rejects.toThrow(
/has not registered with adb/
);
});
});
describe("stopVvd", () => {
let kill: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Don't actually signal real processes from the test runner.
kill = vi.spyOn(process, "kill").mockImplementation(() => true);
});
afterEach(() => kill.mockRestore());
it("does not signal anything when the CLI stop succeeds and no VVD process remains", async () => {
runVega.mockResolvedValue({ stdout: "", stderr: "" });
listRunningVvdPids.mockResolvedValue([]);
await stopVvd();
expect(runVega).toHaveBeenCalledWith(
["virtual-device", "stop"],
expect.objectContaining({ timeoutMs: expect.any(Number) })
);
expect(kill).not.toHaveBeenCalled();
});
it("kills a VVD the CLI refused to stop, and does not rethrow the CLI failure", async () => {
// The CLI exits non-zero ("virtual device not running") for a VVD it lost track of.
runVega.mockRejectedValue(
new Error("vega virtual-device stop failed: virtual device not running")
);
listRunningVvdPids.mockResolvedValue([75137]);
listRunningVvdConsolePorts.mockResolvedValue(new Set()); // isVvdRunning → gone after SIGTERM
await expect(stopVvd({ killGraceMs: 50, verifyPollMs: 5 })).resolves.toBeUndefined();
expect(kill).toHaveBeenCalledWith(75137, "SIGTERM");
expect(kill).not.toHaveBeenCalledWith(75137, "SIGKILL");
});
it("escalates to SIGKILL when SIGTERM leaves the VVD running", async () => {
runVega.mockResolvedValue({ stdout: "", stderr: "" });
listRunningVvdPids.mockResolvedValue([75137]);
listRunningVvdConsolePorts.mockResolvedValue(new Set([5554])); // still running through the grace window
await stopVvd({ killGraceMs: 20, verifyPollMs: 5 });
expect(kill).toHaveBeenCalledWith(75137, "SIGTERM");
expect(kill).toHaveBeenCalledWith(75137, "SIGKILL");
});
it("swallows a throwing process.kill (ESRCH/EPERM) and still signals every pid", async () => {
// signalQuietly must absorb a kill that races the process exiting (ESRCH) or
// hits a pid we don't own (EPERM) — stopVvd must still resolve and must keep
// signalling the remaining pids rather than aborting on the first throw.
runVega.mockResolvedValue({ stdout: "", stderr: "" });
listRunningVvdPids.mockResolvedValue([75137, 80001]);
listRunningVvdConsolePorts.mockResolvedValue(new Set()); // gone after SIGTERM → no SIGKILL pass
kill.mockImplementation(() => {
throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" });
});
await expect(stopVvd({ killGraceMs: 50, verifyPollMs: 5 })).resolves.toBeUndefined();
expect(kill).toHaveBeenCalledWith(75137, "SIGTERM");
expect(kill).toHaveBeenCalledWith(80001, "SIGTERM");
});
});