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
8 changes: 6 additions & 2 deletions packages/agent-installers/src/agents/custom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { agentsDir, ensureDir, type EnvLike } from "@pickforge/picklab-core";
import { writeFileAtomic } from "../atomicFile.js";
import {
agentsDir,
ensureDir,
writeFileAtomic,
type EnvLike,
} from "@pickforge/picklab-core";
import {
MCP_SERVER_NAME,
renderJsonSnippet,
Expand Down
36 changes: 0 additions & 36 deletions packages/agent-installers/src/atomicFile.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/agent-installers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ export {
type RegistrationState,
} from "./types.js";

export { writeFileAtomic } from "./atomicFile.js";

export {
agentsStatePath,
readAgentsState,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-installers/src/jsonConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import { writeFileAtomic } from "./atomicFile.js";
import { writeFileAtomic } from "@pickforge/picklab-core";
import { backupFile } from "./backup.js";
import {
BROWSER_MCP_SERVER_NAME,
Expand Down
3 changes: 1 addition & 2 deletions packages/agent-installers/src/state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { agentsDir, type EnvLike } from "@pickforge/picklab-core";
import { writeFileAtomic } from "./atomicFile.js";
import { agentsDir, writeFileAtomic, type EnvLike } from "@pickforge/picklab-core";

export interface AgentStateEntry {
registered: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-installers/src/tomlConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import { writeFileAtomic } from "./atomicFile.js";
import { writeFileAtomic } from "@pickforge/picklab-core";
import { backupFile } from "./backup.js";
import { renderTomlSnippet } from "./snippet.js";
import type { ChangeResult, McpServerEntry } from "./types.js";
Expand Down
56 changes: 45 additions & 11 deletions packages/android/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getSession,
isPidAlive,
reapDeadRunningSessions,
REAPER_CLEANUP_PENDING_META_KEY,
sessionsDir,
updateSession,
type AndroidSessionInfo,
Expand Down Expand Up @@ -104,18 +105,41 @@ export async function createAndroidSession(
logDir,
};
} catch (error) {
let emulatorGone = true;
if (emulator !== undefined) {
await stopEmulator({
serial: emulator.serial,
pid: emulator.pid,
sdk: opts.sdk,
env: opts.env,
registryEnv,
}).catch(() => {});
try {
emulatorGone = await stopEmulator({
serial: emulator.serial,
pid: emulator.pid,
sdk: opts.sdk,
env: opts.env,
registryEnv,
});
} catch {
emulatorGone = false;
}
}
await updateSession(record.id, { status: "error" }, registryEnv).catch(
() => {},
);
const clearedMeta = { ...record.meta };
delete clearedMeta[REAPER_CLEANUP_PENDING_META_KEY];
await updateSession(
record.id,
emulatorGone
? { status: "error", meta: clearedMeta }
: {
status: "error",
meta: {
...record.meta,
[REAPER_CLEANUP_PENDING_META_KEY]: true,
},
android: {
avdName,
serial: emulator?.serial,
emulatorPid: emulator?.pid,
consolePort: emulator?.consolePort,
},
},
registryEnv,
).catch(() => {});
throw error;
}
}
Expand Down Expand Up @@ -147,7 +171,17 @@ export async function destroyAndroidSession(
failure = error instanceof Error ? error : new Error(String(error));
}
if (!stopped) {
await updateSession(id, { status: "error" }, registryEnv).catch(() => {});
await updateSession(
id,
{
status: "error",
meta: {
...record.meta,
[REAPER_CLEANUP_PENDING_META_KEY]: true,
},
},
registryEnv,
).catch(() => {});
throw new Error(
`Failed to stop emulator of android session ${id} ` +
`(serial ${android.serial ?? "unknown"}, pid ${android.emulatorPid ?? "unknown"})` +
Expand Down
107 changes: 107 additions & 0 deletions packages/android/test/reaper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";

let forceStopFailure = false;

vi.mock("../src/emulator.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/emulator.js")>();
return {
...actual,
stopEmulator: vi.fn(async (opts: Parameters<typeof actual.stopEmulator>[0]) => {
if (forceStopFailure) {
return false;
}
return actual.stopEmulator(opts);
}),
};
});

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

const tmpRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "picklab-android-reaper-"),
);
const home = path.join(tmpRoot, "home");
const projectDir = path.join(tmpRoot, "project");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
const registryEnv: EnvLike = { ...process.env, PICKLAB_HOME: home };

afterAll(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});

function writeExecutable(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, { mode: 0o755 });
}

function makeFakeSdk(): string {
const sdk = path.join(tmpRoot, "sdk");
writeExecutable(
path.join(sdk, "emulator", "emulator"),
"#!/bin/sh\nPATH=/usr/bin:/bin\nexec sleep 60\n",
);
writeExecutable(
path.join(sdk, "platform-tools", "adb"),
[
"#!/bin/sh",
'case "$*" in',
" *getprop*) echo 1 ;;",
' devices) printf "List of devices attached\\nemulator-5554\\tdevice\\n" ;;',
' *"emu kill"*) exit 0 ;;',
"esac",
"exit 0",
].join("\n"),
);
return sdk;
}

describe("android reaper tracking", () => {
it("keeps a leaked android session reaper-trackable after a failed destroy", async () => {
const sdk = makeFakeSdk();
const session = await createAndroidSession({
projectDir,
registryEnv,
sdk,
port: 5554,
env: { PATH: "" },
bootPollIntervalMs: 20,
bootTimeoutMs: 5_000,
});

forceStopFailure = true;
try {
await expect(
destroyAndroidSession(session.id, registryEnv, {
sdk,
env: { PATH: "" },
timeoutMs: 300,
}),
).rejects.toThrow(/Failed to stop emulator/);

const leaked = await getSession(session.id, registryEnv);
expect(leaked?.status).toBe("error");
expect(leaked?.meta?.[REAPER_CLEANUP_PENDING_META_KEY]).toBe(true);
expect(leaked?.android?.emulatorPid).toBe(session.emulatorPid);
expect(isPidAlive(session.emulatorPid)).toBe(true);
} finally {
forceStopFailure = false;
}

const reaped = await reapDeadRunningSessions(registryEnv);
expect(reaped.map((record) => record.id)).toContain(session.id);
expect(await getSession(session.id, registryEnv)).toBeUndefined();
expect(isPidAlive(session.emulatorPid)).toBe(false);
}, 20_000);
});
6 changes: 6 additions & 0 deletions packages/browser/src/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const child = spawn(binary, args, {
stdio: ["inherit", "inherit", "pipe"],
});
let stderrBuffer = "";
const MAX_STDERR_BUFFER_BYTES = 64 * 1024;
function forwardBrowserStderr(line) {
process.stderr.write(
line.replace(
Expand All @@ -64,6 +65,11 @@ child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderrBuffer += chunk;
flushBrowserStderr();
// No newline arrived to flush a complete line; bound the buffer so an
// unbounded stream of newline-free stderr can never grow it forever.
if (stderrBuffer.length > MAX_STDERR_BUFFER_BYTES) {
stderrBuffer = stderrBuffer.slice(-MAX_STDERR_BUFFER_BYTES);
}
});
child.stderr.once("end", () => flushBrowserStderr(true));
child.once("error", (error) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/browser/test/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ describe("buildSupervisedBrowserCommand", () => {
}
});

it("bounds unbounded newline-free stderr instead of buffering it all", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "picklab-supervisor-"));
const browser = path.join(tmp, "browser.cjs");
fs.writeFileSync(
browser,
[
'const fs = require("fs");',
'fs.writeSync(2, "HEAD_MARKER_ABC");',
'fs.writeSync(2, "a".repeat(5 * 1024 * 1024));',
'fs.writeSync(2, "TAIL_MARKER_XYZ");',
"process.exit(0);",
].join("\n"),
);
try {
const command = buildSupervisedBrowserCommand(
process.execPath,
process.execPath,
[browser],
);
const result = await runCommand(command.command, command.args);

expect(result.ok).toBe(true);
expect(result.stderr).toContain("TAIL_MARKER_XYZ");
expect(result.stderr).not.toContain("HEAD_MARKER_ABC");
expect(result.stderr.length).toBeLessThan(200 * 1024);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("rejects missing executable paths", () => {
expect(() =>
buildSupervisedBrowserCommand("", "/usr/bin/chromium", []),
Expand Down
17 changes: 3 additions & 14 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ensureDir,
globalConfigPath,
projectConfigPath,
writeFileAtomic,
type EnvLike,
} from "./paths.js";

Expand Down Expand Up @@ -102,24 +103,12 @@ export async function loadConfig(
) as PicklabConfig;
}

let tmpCounter = 0;

async function writeConfigFile(
filePath: string,
config: PicklabConfig,
): Promise<void> {
const dir = await ensureDir(path.dirname(filePath));
tmpCounter += 1;
const tmp = path.join(
dir,
`.${path.basename(filePath)}.tmp-${process.pid}-${tmpCounter}`,
);
await fs.promises.writeFile(
tmp,
`${JSON.stringify(config, null, 2)}\n`,
"utf8",
);
await fs.promises.rename(tmp, filePath);
await ensureDir(path.dirname(filePath));
await writeFileAtomic(filePath, `${JSON.stringify(config, null, 2)}\n`);
}

export async function saveProjectConfig(
Expand Down
Loading
Loading