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
7 changes: 7 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ then reset this file.

## Internal/release changes

- Routed dead-session reaping through the desktop, Android, and browser typed
teardown owners. Core now owns only registry/liveness/retry/evidence policy,
eliminating its duplicate PID and browser-profile stop implementation. CLI
and MCP creation compose all owners; direct package creation reaps its own
runtime type.
- Stabilized the browser Xvfb cancellation regression test by waiting for the
fake process's complete PID/display marker instead of treating file creation
as publication. This removes the full-suite/coverage race that could abandon
Expand Down Expand Up @@ -260,6 +265,8 @@ then reset this file.
browser + desktop-linux suite iterations pass with a deliberately split
fake startup-marker publication; the browser session suite (25 tests) and
`bun run typecheck` pass.
- Typed reaper teardown: core plus desktop-linux, Android, and browser suites;
CLI/MCP session tests; `bun run typecheck`; and `bun run build` pass.

- Evidence storage foundation: `bun run typecheck`, `bun run build`, and the
full suite pass (73 files, 895 passed / 2 skipped). New core coverage
Expand Down
3 changes: 3 additions & 0 deletions packages/android/src/emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,9 @@ async function stopEmulatorProcess(
opts: StopEmulatorOptions,
): Promise<boolean> {
const timeoutMs = opts.timeoutMs ?? EMU_KILL_TIMEOUT_MS;
if (opts.pid !== undefined && !isPidAlive(opts.pid)) {
return true;
}
let adbPath: string | null = null;
try {
adbPath = resolveAdb(opts);
Expand Down
1 change: 1 addition & 0 deletions packages/android/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export {
createAndroidSession,
destroyAndroidSession,
getAndroidSessionStatus,
teardownAndroidSession,
type AndroidSessionHandle,
type AndroidSessionOpOptions,
type AndroidSessionStatus,
Expand Down
32 changes: 27 additions & 5 deletions packages/android/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
updateSession,
type AndroidSessionInfo,
type EnvLike,
type LocalSessionTeardownFinalizer,
type SessionRecord,
} from "@pickforge/picklab-core";
import { listDevices } from "./adb.js";
Expand Down Expand Up @@ -64,7 +65,17 @@ export async function createAndroidSession(
): Promise<AndroidSessionHandle> {
const registryEnv = opts.registryEnv ?? process.env;
const avdName = opts.avdName ?? DEFAULT_AVD_NAME;
await reapDeadRunningSessions(registryEnv);
await reapDeadRunningSessions(registryEnv, {
android: {
teardown: (id, finalize) =>
teardownAndroidSession(
id,
registryEnv,
{ sdk: opts.sdk, env: opts.env },
finalize,
),
},
});
const record = await createSession(
{ type: "android", projectDir: opts.projectDir, android: { avdName } },
registryEnv,
Expand Down Expand Up @@ -144,10 +155,11 @@ export async function createAndroidSession(
}
}

export async function destroyAndroidSession(
export async function teardownAndroidSession(
id: string,
registryEnv: EnvLike = process.env,
opts: AndroidSessionOpOptions = {},
registryEnv: EnvLike,
opts: AndroidSessionOpOptions,
finalize: LocalSessionTeardownFinalizer,
): Promise<void> {
const record = await getSession(id, registryEnv);
if (record === undefined) {
Expand Down Expand Up @@ -189,7 +201,17 @@ export async function destroyAndroidSession(
);
}
}
await destroySessionRecord(id, registryEnv);
await finalize();
}

export async function destroyAndroidSession(
id: string,
registryEnv: EnvLike = process.env,
opts: AndroidSessionOpOptions = {},
): Promise<void> {
await teardownAndroidSession(id, registryEnv, opts, () =>
destroySessionRecord(id, registryEnv),
);
}

export async function getAndroidSessionStatus(
Expand Down
21 changes: 21 additions & 0 deletions packages/android/test/emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,27 @@ describe("sdk auto-detection in the execution layer", () => {
});

describe("stopEmulator confirmation", () => {
it("does not send adb kill when the recorded pid is already dead", async () => {
const marker = path.join(tmpRoot, `adb-kill-${sdkCounter + 1}.txt`);
const sdk = makeFakeSdk(`printf '%s\\n' "$*" >> ${JSON.stringify(marker)}`);
const registryEnv = makeRegistryEnv();
const pid = await deadPid();
expect(tryReserveConsolePort(5568, registryEnv, pid)).toBe(true);

expect(
await stopEmulator({
serial: "emulator-5568",
pid,
sdk,
env: { PATH: "" },
registryEnv,
timeoutMs: 300,
}),
).toBe(true);
expect(fs.existsSync(marker)).toBe(false);
expect(fs.existsSync(consolePortLockPath(5568, registryEnv))).toBe(false);
});

it("returns false when adb devices cannot confirm the shutdown", async () => {
const sdk = makeFakeSdk(
[
Expand Down
26 changes: 24 additions & 2 deletions packages/android/test/reaper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ vi.mock("../src/emulator.js", async (importOriginal) => {

import {
REAPER_CLEANUP_PENDING_META_KEY,
beginEvidenceRun,
getSession,
isPidAlive,
reapDeadRunningSessions,
type EnvLike,
} from "@pickforge/picklab-core";
import { createAndroidSession, destroyAndroidSession } from "../src/index.js";
import {
createAndroidSession,
destroyAndroidSession,
teardownAndroidSession,
} from "../src/index.js";

const tmpRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "picklab-android-reaper-"),
Expand Down Expand Up @@ -80,6 +85,8 @@ describe("android reaper tracking", () => {
bootTimeoutMs: 5_000,
});

const { run } = await beginEvidenceRun(projectDir, session.id);

forceStopFailure = true;
try {
await expect(
Expand All @@ -99,9 +106,24 @@ describe("android reaper tracking", () => {
forceStopFailure = false;
}

const reaped = await reapDeadRunningSessions(registryEnv);
const reaped = await reapDeadRunningSessions(registryEnv, {
android: {
teardown: (id, finalize) =>
teardownAndroidSession(
id,
registryEnv,
{ sdk, env: { PATH: "" }, timeoutMs: 300 },
finalize,
),
},
});
expect(reaped.map((record) => record.id)).toContain(session.id);
expect(await getSession(session.id, registryEnv)).toBeUndefined();
expect(isPidAlive(session.emulatorPid)).toBe(false);
expect(
JSON.parse(
await fs.promises.readFile(path.join(run.dir, "manifest.json"), "utf8"),
),
).toMatchObject({ status: "failed" });
}, 20_000);
});
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
createBrowserSession,
destroyBrowserSession,
getBrowserSessionStatus,
teardownBrowserSession,
type BrowserSessionHandle,
type BrowserSessionStatus,
type CreateBrowserSessionOptions,
Expand Down
24 changes: 20 additions & 4 deletions packages/browser/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type BrowserSessionInfo,
type DesktopSessionInfo,
type EnvLike,
type LocalSessionTeardownFinalizer,
type OwnedDaemonHandle,
type ProcessIdentity,
type SessionRecord,
Expand Down Expand Up @@ -218,7 +219,12 @@ export async function createBrowserSession(
...(opts.binaryPath !== undefined ? { binaryPath: opts.binaryPath } : {}),
});

await reapDeadRunningSessions(registryEnv);
await reapDeadRunningSessions(registryEnv, {
browser: {
teardown: (id, finalize) =>
teardownBrowserSession(id, registryEnv, finalize),
},
});
assertNotAborted(opts.signal);
const record = await createSession(
{ type: "browser", projectDir: opts.projectDir },
Expand Down Expand Up @@ -489,9 +495,10 @@ export async function getBrowserSessionStatus(
* into one error
* and leave the record in `error` state for inspection.
*/
export async function destroyBrowserSession(
export async function teardownBrowserSession(
id: string,
registryEnv: EnvLike = process.env,
registryEnv: EnvLike,
finalize: LocalSessionTeardownFinalizer,
): Promise<void> {
const initial = await getSession(id, registryEnv);
if (initial === undefined) {
Expand Down Expand Up @@ -624,6 +631,15 @@ export async function destroyBrowserSession(
`Failed to fully destroy browser session ${id}`,
);
}
await destroySessionRecord(id, registryEnv);
await finalize();
});
}

export async function destroyBrowserSession(
id: string,
registryEnv: EnvLike = process.env,
): Promise<void> {
await teardownBrowserSession(id, registryEnv, () =>
destroySessionRecord(id, registryEnv),
);
}
23 changes: 19 additions & 4 deletions packages/browser/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
destroyBrowserSession,
getBrowserSessionStatus,
resolveLiveBrowserSession,
teardownBrowserSession,
type BrowserSessionHandle,
} from "../src/index.js";
import { fakePath, writeExecutable, writeFakeChrome } from "./fakes.js";
Expand Down Expand Up @@ -58,6 +59,15 @@ afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

function reapBrowserSessions() {
return reapDeadRunningSessions(registryEnv, {
browser: {
teardown: (id, finalize) =>
teardownBrowserSession(id, registryEnv, finalize),
},
});
}

function spawnEnvFor(
mode: FakeChromeMode,
extra: EnvLike = {},
Expand Down Expand Up @@ -538,9 +548,7 @@ describe.skipIf(!hasXvfb)("destroyBrowserSession (fake binaries)", () => {
registryEnv,
);
expect(
(await reapDeadRunningSessions(registryEnv)).map(
(reaped) => reaped.id,
),
(await reapBrowserSessions()).map((reaped) => reaped.id),
).toEqual([session.id]);
expect(await getSession(session.id, registryEnv)).toBeUndefined();
expect(fs.existsSync(session.profileDir)).toBe(false);
Expand Down Expand Up @@ -891,7 +899,7 @@ describe.skipIf(!hasXvfb)("partial-failure cleanup (fake binaries)", () => {

await stopPid(childPid, { timeoutMs: 1000 });
expect(
(await reapDeadRunningSessions(registryEnv)).map((record) => record.id),
(await reapBrowserSessions()).map((record) => record.id),
).toEqual([id]);
expect(await getSession(id, registryEnv)).toBeUndefined();
expect(isPidAlive(failedXvfbPid)).toBe(false);
Expand Down Expand Up @@ -1066,6 +1074,13 @@ describe("browser record inspection (no live processes)", () => {
);
// The confinement guard must not have deleted the out-of-tree directory.
expect(fs.existsSync(outside)).toBe(true);
expect((await getSession(rec.id, registryEnv))?.meta?.reaperCleanupPending).toBe(
true,
);

expect(await reapBrowserSessions()).toEqual([]);
expect(await getSession(rec.id, registryEnv)).toBeDefined();
expect(fs.existsSync(outside)).toBe(true);
});

it("refuses a symlinked profile and never removes its outside target", async () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import {
createAndroidSession,
destroyAndroidSession,
getAndroidSessionStatus,
teardownAndroidSession,
} from "@pickforge/picklab-android";
import {
createBrowserSession,
destroyBrowserSession,
getBrowserSessionStatus,
teardownBrowserSession,
} from "@pickforge/picklab-browser";
import {
createLocalSessions,
Expand All @@ -15,18 +17,21 @@ import {
listSessions,
loadConfig,
localSessionStatusEntry,
reapDeadRunningSessions,
type LocalSessionCreateRuntime,
type LocalSessionDestroyRuntime,
type LocalSessionRecipe,
type LocalSessionStatusEntry,
type LocalSessionStatusRuntime,
type LocalSessionSummary,
type LocalSessionTeardownRuntime,
type SessionRecord,
} from "@pickforge/picklab-core";
import {
createDesktopSession,
destroyDesktopSession,
getDesktopSessionStatus,
teardownDesktopSession,
} from "@pickforge/picklab-desktop-linux";
import {
parseIntArg,
Expand Down Expand Up @@ -104,6 +109,21 @@ const destroyRuntime: LocalSessionDestroyRuntime = {
android: { destroy: (id) => destroyAndroidSession(id) },
};

const reaperRuntime: LocalSessionTeardownRuntime = {
desktop: {
teardown: (id, finalize) =>
teardownDesktopSession(id, process.env, finalize),
},
browser: {
teardown: (id, finalize) =>
teardownBrowserSession(id, process.env, finalize),
},
android: {
teardown: (id, finalize) =>
teardownAndroidSession(id, process.env, {}, finalize),
},
};

function describeCreated(summary: LocalSessionSummary): string {
if (summary.type === "desktop") {
const vnc =
Expand Down Expand Up @@ -153,6 +173,7 @@ export async function runSessionCreate(
(viewerMode === "auto" &&
(!createsWritableDesktop || opts.vncControl !== true)));

await reapDeadRunningSessions(process.env, reaperRuntime);
const sessions = await createLocalSessions(opts.type, createRuntime(opts));

const lines = sessions.map(describeCreated);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export {
destroyLocalSession,
destroyLocalSessions,
localSessionStatusEntry,
reapDeadRunningSessions,
teardownLocalSession,
type AndroidLegHandle,
type AndroidLiveStatus,
type BrowserLegHandle,
Expand All @@ -151,6 +153,8 @@ export {
type LocalSessionStatusEntry,
type LocalSessionStatusRuntime,
type LocalSessionSummary,
type LocalSessionTeardownFinalizer,
type LocalSessionTeardownRuntime,
} from "./session-lifecycle.js";

export {
Expand All @@ -161,7 +165,6 @@ export {
isDisplaySocketAlive,
isSessionProcessAlive,
listSessions,
reapDeadRunningSessions,
updateSession,
type AndroidSessionInfo,
type BrowserSessionInfo,
Expand Down
Loading
Loading