@proagentstore/cli 0.4.33 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -40,7 +40,11 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
40
40
|
const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
|
|
41
41
|
switch (cmd) {
|
|
42
42
|
case "status":
|
|
43
|
-
|
|
43
|
+
// `--branch` adds ONE header line (`## main...origin/main [ahead 1]`). Without it a
|
|
44
|
+
// caller can learn a tree is dirty but never which branch it is dirty on, which is how
|
|
45
|
+
// a delegated run pushed a PR branch and left the checkout parked there unnoticed
|
|
46
|
+
// (#276). Additive: every existing consumer keeps the same file lines it always got.
|
|
47
|
+
return ["status", "--short", "--branch"];
|
|
44
48
|
case "diff":
|
|
45
49
|
return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
|
|
46
50
|
case "diff-stat":
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { startRunnerServer } from "./server.js";
|
|
5
|
+
import { reapOnStartup } from "./reaper.js";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
// Resilience: a stray error in any runtime must NOT take the whole runner down —
|
|
7
8
|
// that drops the tunnel and forces the user to restart `pags up` (and lose their
|
|
@@ -71,6 +72,12 @@ if (!config.token && !LOOPBACK.has(config.host)) {
|
|
|
71
72
|
`Set --token (or PAGS_RUNNER_TOKEN), or bind to 127.0.0.1.\n`);
|
|
72
73
|
process.exit(1);
|
|
73
74
|
}
|
|
75
|
+
// Recover a machine that has already leaked (#274). A browser whose launcher was
|
|
76
|
+
// SIGKILLed is reparented to init and runs forever — 41 of them took the owner's
|
|
77
|
+
// machine to load 253. Nothing in-process can clean those up, so we do it here,
|
|
78
|
+
// before we start competing for the same CPU. Narrow by construction: see the
|
|
79
|
+
// SAFETY block in reaper.ts for why the user's real Chrome can never match.
|
|
80
|
+
reapOnStartup((line) => process.stderr.write(`${line}\n`));
|
|
74
81
|
const started = await startRunnerServer(config);
|
|
75
82
|
process.stdout.write(`ProAgentStore browser runtime listening at ${started.url}\n`);
|
|
76
83
|
process.stdout.write(`Data dir: ${config.dataDir}\n`);
|
|
@@ -79,12 +86,47 @@ if (config.token)
|
|
|
79
86
|
process.stdout.write("Auth: bearer token required\n");
|
|
80
87
|
if (config.instanceId)
|
|
81
88
|
process.stdout.write(`Instance binding: ${config.instanceId}\n`);
|
|
89
|
+
/**
|
|
90
|
+
* Exit once, and ALWAYS exit (#274).
|
|
91
|
+
*
|
|
92
|
+
* Three holes lived here, and each one ended with a user reaching for `kill -9`,
|
|
93
|
+
* which is precisely the signal that orphans the browser:
|
|
94
|
+
*
|
|
95
|
+
* - `started.close()` rejects if the browser is already gone. `void shutdown()`
|
|
96
|
+
* then produced an unhandled rejection, the handler above logged it, and the
|
|
97
|
+
* process stayed up forever holding the port and the browser.
|
|
98
|
+
* - A hung `browserContext.close()` (a wedged renderer) blocked exit with no
|
|
99
|
+
* upper bound, looking identical to a freeze.
|
|
100
|
+
* - Two signals, or a signal racing the parent-death watchdog, ran the teardown
|
|
101
|
+
* twice concurrently.
|
|
102
|
+
*
|
|
103
|
+
* So: idempotent, failure-tolerant, and time-boxed. `process.exit` also runs
|
|
104
|
+
* Playwright's own synchronous exit handler, which kills any browser it launched
|
|
105
|
+
* in this process — that is what makes a clean exit leave nothing behind.
|
|
106
|
+
*/
|
|
107
|
+
let exiting = false;
|
|
82
108
|
const shutdown = async () => {
|
|
83
|
-
|
|
109
|
+
if (exiting)
|
|
110
|
+
return;
|
|
111
|
+
exiting = true;
|
|
112
|
+
const forced = setTimeout(() => {
|
|
113
|
+
process.stderr.write("[runner] shutdown timed out after 10s; exiting anyway\n");
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}, 10_000);
|
|
116
|
+
forced.unref();
|
|
117
|
+
try {
|
|
118
|
+
await started.close();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
process.stderr.write(`[runner] shutdown error (exiting anyway): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
122
|
+
}
|
|
84
123
|
process.exit(0);
|
|
85
124
|
};
|
|
86
125
|
process.on("SIGINT", () => void shutdown());
|
|
87
126
|
process.on("SIGTERM", () => void shutdown());
|
|
127
|
+
// SIGHUP was missing: closing the terminal that ran `pags up` killed the runner
|
|
128
|
+
// without ever running teardown, leaving the browser behind.
|
|
129
|
+
process.on("SIGHUP", () => void shutdown());
|
|
88
130
|
// Self-exit if our parent (the `runner connect` CLI) dies — otherwise we'd orphan
|
|
89
131
|
// and keep holding the port, making the NEXT `pags up` fail with EADDRINUSE / a 401
|
|
90
132
|
// against our stale token. When the parent dies we're reparented (ppid changes to
|
|
@@ -1,6 +1,76 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import { createConnection } from "@playwright/mcp";
|
|
2
5
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
6
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
|
+
import { sweepStalePlaywrightProfiles } from "./reaper.js";
|
|
8
|
+
/**
|
|
9
|
+
* Throwaway profile dirs created by this module and not yet removed (#274).
|
|
10
|
+
*
|
|
11
|
+
* `stop()` cannot reliably win this race on its own: the MCP server releases the
|
|
12
|
+
* browser, but Chrome's exit is asynchronous and it rewrites `Local State` on the
|
|
13
|
+
* way out — measurably AFTER `stop()` has returned, so an rmSync there leaves a
|
|
14
|
+
* re-created directory behind. The browser is only certainly gone once Playwright's
|
|
15
|
+
* own `exit` handler has run, so the last pass belongs at process exit.
|
|
16
|
+
*
|
|
17
|
+
* This also covers the dir being stranded when `stop()` is never called at all —
|
|
18
|
+
* a throw between `start()` and `stop()`, which no `try/finally` in the caller
|
|
19
|
+
* would catch either.
|
|
20
|
+
*/
|
|
21
|
+
const ownedProfileDirs = new Set();
|
|
22
|
+
let exitHookInstalled = false;
|
|
23
|
+
/**
|
|
24
|
+
* Try to remove a profile dir now. It stays registered unless it is really gone,
|
|
25
|
+
* so a dir Chrome re-creates while shutting down gets another pass at exit rather
|
|
26
|
+
* than being forgotten after one failed attempt.
|
|
27
|
+
*/
|
|
28
|
+
function removeProfileDir(dir) {
|
|
29
|
+
try {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// best effort; the dir is disk, not correctness
|
|
34
|
+
}
|
|
35
|
+
if (!existsSync(dir))
|
|
36
|
+
ownedProfileDirs.delete(dir);
|
|
37
|
+
}
|
|
38
|
+
/** Await a promise, giving up (never throwing) after `ms`. */
|
|
39
|
+
async function withTimeout(p, ms) {
|
|
40
|
+
if (!p)
|
|
41
|
+
return;
|
|
42
|
+
let timer;
|
|
43
|
+
try {
|
|
44
|
+
await Promise.race([
|
|
45
|
+
p.catch(() => undefined),
|
|
46
|
+
new Promise((resolve) => {
|
|
47
|
+
timer = setTimeout(resolve, ms);
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (timer)
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function rememberProfileDir(dir) {
|
|
57
|
+
ownedProfileDirs.add(dir);
|
|
58
|
+
if (exitHookInstalled)
|
|
59
|
+
return;
|
|
60
|
+
exitHookInstalled = true;
|
|
61
|
+
// Registered AFTER Playwright's own launch-time exit handler, so by the time
|
|
62
|
+
// this runs Playwright has already killed the browsers it started.
|
|
63
|
+
process.on("exit", () => {
|
|
64
|
+
for (const d of ownedProfileDirs) {
|
|
65
|
+
try {
|
|
66
|
+
rmSync(d, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// exiting anyway
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
4
74
|
/**
|
|
5
75
|
* Hosts the INDUSTRY-STANDARD `@playwright/mcp` server in the runner and an in-process
|
|
6
76
|
* MCP client to drive it. Every browser action goes through the standard Playwright MCP
|
|
@@ -11,12 +81,51 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
|
11
81
|
export class McpRuntime {
|
|
12
82
|
server;
|
|
13
83
|
client;
|
|
84
|
+
/** A profile dir this instance created and is therefore responsible for deleting. */
|
|
85
|
+
ownedProfileDir;
|
|
14
86
|
async start(opts) {
|
|
15
87
|
if (this.client)
|
|
16
88
|
return;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
:
|
|
89
|
+
let browser;
|
|
90
|
+
if (opts.cdpEndpoint) {
|
|
91
|
+
// Production: attach to the browser the runner already launched. Nothing
|
|
92
|
+
// is created here, so nothing can leak here.
|
|
93
|
+
browser = { cdpEndpoint: opts.cdpEndpoint };
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// This is the ONE path in this package that produced the `#274` orphans.
|
|
97
|
+
//
|
|
98
|
+
// `isolated: true` makes @playwright/mcp call the non-persistent
|
|
99
|
+
// `chromium.launch()`, and Playwright then mkdtemps
|
|
100
|
+
// `$TMPDIR/playwright_chromiumdev_profile-XXXXXX` and deletes it only on a
|
|
101
|
+
// clean close — so every SIGKILLed parent left both a running browser AND a
|
|
102
|
+
// few hundred MB of profile behind, under a name we did not know and could
|
|
103
|
+
// not clean up afterwards.
|
|
104
|
+
//
|
|
105
|
+
// Asking for a directory WE created gets the same isolation (it is fresh
|
|
106
|
+
// every start) while making the leak addressable: we know the path, we
|
|
107
|
+
// delete it in `stop()`, and it is named so a human can see where it came
|
|
108
|
+
// from. It also keeps the throwaway-profile pattern out of our own output,
|
|
109
|
+
// so the reaper's matches are unambiguously other people's abandonment.
|
|
110
|
+
// Self-heal before adding one more. Chrome writes its final state as it
|
|
111
|
+
// dies — after `stop()`, and after our own `exit` hook — so the last
|
|
112
|
+
// skeleton of a directory can outlive the process that owned it no matter
|
|
113
|
+
// when we try to delete it. Sweeping here means a stale one never survives
|
|
114
|
+
// a second run, without needing the runner to be restarted. Only touches
|
|
115
|
+
// dirs no live process holds; see reaper.ts for the safety argument.
|
|
116
|
+
try {
|
|
117
|
+
sweepStalePlaywrightProfiles();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// cleanup must never stop a browser from starting
|
|
121
|
+
}
|
|
122
|
+
const userDataDir = opts.userDataDir ?? mkdtempSync(join(tmpdir(), "pags-mcp-profile-"));
|
|
123
|
+
if (!opts.userDataDir) {
|
|
124
|
+
this.ownedProfileDir = userDataDir;
|
|
125
|
+
rememberProfileDir(userDataDir);
|
|
126
|
+
}
|
|
127
|
+
browser = { userDataDir, isolated: false, launchOptions: { headless: opts.headless ?? false } };
|
|
128
|
+
}
|
|
20
129
|
// The runner is a trusted local process uploading the user's OWN résumé, which
|
|
21
130
|
// lives under the runner's data dir (outside the CWD). Lift the file-root guard
|
|
22
131
|
// (meant to stop an LLM reading arbitrary host files) so browser_file_upload can
|
|
@@ -40,9 +149,22 @@ export class McpRuntime {
|
|
|
40
149
|
return (res.content || []).map((c) => c.text ?? "").join("\n");
|
|
41
150
|
}
|
|
42
151
|
async stop() {
|
|
43
|
-
|
|
44
|
-
|
|
152
|
+
// Bounded. Closing a persistent context flushes the whole profile to disk and
|
|
153
|
+
// on a cold/slow machine that measurably exceeds 10s — and a browser wedged
|
|
154
|
+
// mid-close would otherwise block teardown with no upper limit at all, which
|
|
155
|
+
// is the state that ends in someone sending the SIGKILL that orphans it (#274).
|
|
156
|
+
// Returning early is safe: the `exit` hook and reaper.ts's sweep both still run.
|
|
157
|
+
await withTimeout(this.client?.close(), 20_000);
|
|
158
|
+
await withTimeout(this.server?.close(), 20_000);
|
|
45
159
|
this.client = undefined;
|
|
46
160
|
this.server = undefined;
|
|
161
|
+
// Remove the profile only if we made it — a caller-supplied dir belongs to
|
|
162
|
+
// the caller. A best-effort pass now, ordered after the closes so the browser
|
|
163
|
+
// has released its locks; the `exit` hook above is the pass that always runs,
|
|
164
|
+
// and reaper.ts's startup sweep covers a process that was killed outright.
|
|
165
|
+
if (this.ownedProfileDir) {
|
|
166
|
+
removeProfileDir(this.ownedProfileDir);
|
|
167
|
+
this.ownedProfileDir = undefined;
|
|
168
|
+
}
|
|
47
169
|
}
|
|
48
170
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* A throwaway browser-profile directory name, anchored.
|
|
8
|
+
*
|
|
9
|
+
* Two producers, both of which mkdtemp — so the trailing six characters are
|
|
10
|
+
* exactly what `fs.mkdtemp` appends, and pinning that length is what stops a
|
|
11
|
+
* user-chosen directory which merely CONTAINS this text from matching:
|
|
12
|
+
*
|
|
13
|
+
* - `playwright_<browser>dev_profile-` — Playwright's own, created per
|
|
14
|
+
* non-persistent `launch()`. These are the orphans measured in #274.
|
|
15
|
+
* - `pags-mcp-profile-` — ours, from `McpRuntime` (see mcp-runtime.ts). Chrome
|
|
16
|
+
* can rewrite this directory during its async shutdown, after `stop()` has
|
|
17
|
+
* already removed it, so it needs a sweeper too.
|
|
18
|
+
*
|
|
19
|
+
* A name matching neither is never touched, whatever it contains.
|
|
20
|
+
*/
|
|
21
|
+
const TEMP_PROFILE_NAME = /^(?:playwright_[a-z]+dev_profile|pags-mcp-profile)-[A-Za-z0-9]{6}$/;
|
|
22
|
+
/** Every path the system might hand out as the temp root, de-duplicated. */
|
|
23
|
+
export function tempRoots() {
|
|
24
|
+
const roots = new Set();
|
|
25
|
+
for (const root of [tmpdir(), process.env.TMPDIR, "/tmp"]) {
|
|
26
|
+
if (!root)
|
|
27
|
+
continue;
|
|
28
|
+
roots.add(root.replace(/\/+$/, ""));
|
|
29
|
+
}
|
|
30
|
+
return [...roots];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Is this `--user-data-dir` a Playwright throwaway temp profile?
|
|
34
|
+
*
|
|
35
|
+
* Both halves matter. The basename pattern says "Playwright mkdtemp'd this"; the
|
|
36
|
+
* temp-root check says "and it is under the system temp dir, not somewhere a
|
|
37
|
+
* human keeps a real profile". A directory that satisfies only one is rejected.
|
|
38
|
+
*/
|
|
39
|
+
export function isPlaywrightTempProfile(userDataDir, roots = tempRoots()) {
|
|
40
|
+
if (!userDataDir)
|
|
41
|
+
return false;
|
|
42
|
+
const dir = userDataDir.replace(/\/+$/, "");
|
|
43
|
+
if (!TEMP_PROFILE_NAME.test(basename(dir)))
|
|
44
|
+
return false;
|
|
45
|
+
const parent = dirname(dir).replace(/\/+$/, "");
|
|
46
|
+
return roots.some((root) => parent === root);
|
|
47
|
+
}
|
|
48
|
+
/** Pull `--user-data-dir=<path>` out of a command line. "" when absent. */
|
|
49
|
+
export function userDataDirOf(command) {
|
|
50
|
+
// Deliberately `\S+`: every path this reaper acts on is a mkdtemp name under
|
|
51
|
+
// the temp root, which never contains a space. A path WITH a space therefore
|
|
52
|
+
// fails to parse and is skipped — the safe direction to be wrong in.
|
|
53
|
+
const m = command.match(/--user-data-dir=(\S+)/);
|
|
54
|
+
return m ? m[1] : "";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse `ps -o etime` — `[[DD-]HH:]MM:SS` — into seconds. Returns 0 for anything
|
|
58
|
+
* unparseable, which reads as "brand new" and therefore never reapable.
|
|
59
|
+
*/
|
|
60
|
+
export function parseEtime(etime) {
|
|
61
|
+
const trimmed = etime.trim();
|
|
62
|
+
const m = trimmed.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
63
|
+
if (!m)
|
|
64
|
+
return 0;
|
|
65
|
+
const [, days, hours, minutes, seconds] = m;
|
|
66
|
+
return Number(days ?? 0) * 86400 + Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds);
|
|
67
|
+
}
|
|
68
|
+
/** Parse `ps -wwAo pid=,ppid=,etime=,command=` output into candidate rows. */
|
|
69
|
+
export function parsePsOutput(stdout) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of stdout.split("\n")) {
|
|
72
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
73
|
+
if (!m)
|
|
74
|
+
continue;
|
|
75
|
+
const userDataDir = userDataDirOf(m[4]);
|
|
76
|
+
if (!userDataDir)
|
|
77
|
+
continue;
|
|
78
|
+
out.push({ pid: Number(m[1]), ppid: Number(m[2]), ageSeconds: parseEtime(m[3]), userDataDir });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Age of a profile directory's last write, in ms.
|
|
84
|
+
*
|
|
85
|
+
* A MISSING directory returns Infinity, and that is deliberate rather than a
|
|
86
|
+
* fallback: Playwright removes this directory only when the browser closed
|
|
87
|
+
* cleanly. A process still running on a temp profile that no longer exists is
|
|
88
|
+
* unambiguously abandoned.
|
|
89
|
+
*/
|
|
90
|
+
export function profileIdleMs(dir, now, stat = safeMtimeMs) {
|
|
91
|
+
const mtime = stat(dir);
|
|
92
|
+
return mtime === null ? Number.POSITIVE_INFINITY : now - mtime;
|
|
93
|
+
}
|
|
94
|
+
function safeMtimeMs(path) {
|
|
95
|
+
try {
|
|
96
|
+
return statSync(path).mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The whole safety decision, as one pure function — see the SAFETY block above.
|
|
104
|
+
* All four conditions must hold; any one of them failing spares the process.
|
|
105
|
+
*/
|
|
106
|
+
export function isReapable(proc, opts) {
|
|
107
|
+
if (proc.ppid !== 1)
|
|
108
|
+
return false;
|
|
109
|
+
if (!isPlaywrightTempProfile(proc.userDataDir, opts.roots))
|
|
110
|
+
return false;
|
|
111
|
+
if (proc.ageSeconds * 1000 < opts.minAgeMs)
|
|
112
|
+
return false;
|
|
113
|
+
const idle = (opts.idleMs ?? profileIdleMs)(proc.userDataDir, opts.now);
|
|
114
|
+
return idle >= opts.minAgeMs;
|
|
115
|
+
}
|
|
116
|
+
function ps() {
|
|
117
|
+
try {
|
|
118
|
+
// -ww: never truncate the command line, or the --user-data-dir we match on
|
|
119
|
+
// could be cut off and a real orphan would go unnoticed.
|
|
120
|
+
return execFileSync("ps", ["-wwAo", "pid=,ppid=,etime=,command="], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Every Playwright-temp-profile browser process currently alive, orphan or not. */
|
|
127
|
+
export function listPlaywrightBrowsers(psOutput = ps()) {
|
|
128
|
+
return parsePsOutput(psOutput).filter((p) => isPlaywrightTempProfile(p.userDataDir));
|
|
129
|
+
}
|
|
130
|
+
function signal(pid, sig) {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, sig);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// already gone, or not ours to signal — either way there is nothing to do
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function alive(pid) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid, 0);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Kill abandoned Playwright browsers and delete their temp profiles.
|
|
149
|
+
*
|
|
150
|
+
* Synchronous on purpose: this runs once at runner startup, before anything else
|
|
151
|
+
* competes for the machine, and the whole point is that the CPU is already gone.
|
|
152
|
+
*/
|
|
153
|
+
export function reapOrphanedPlaywrightBrowsers(opts = {}) {
|
|
154
|
+
const log = opts.log ?? ((line) => console.warn(line));
|
|
155
|
+
const empty = { reaped: [], skippedYoung: 0 };
|
|
156
|
+
if (process.platform === "win32")
|
|
157
|
+
return empty; // no `ps`; the leak is a POSIX-orphan story
|
|
158
|
+
if (process.pid === 1) {
|
|
159
|
+
// See SAFETY note 2: as PID 1 we cannot tell an orphan from our own live child.
|
|
160
|
+
return empty;
|
|
161
|
+
}
|
|
162
|
+
const now = opts.now ?? Date.now();
|
|
163
|
+
const minAgeMs = opts.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
|
164
|
+
const all = listPlaywrightBrowsers();
|
|
165
|
+
const orphanParents = all.filter((p) => p.ppid === 1);
|
|
166
|
+
const doomed = orphanParents.filter((p) => isReapable(p, { now, minAgeMs }));
|
|
167
|
+
const skippedYoung = orphanParents.length - doomed.length;
|
|
168
|
+
if (doomed.length === 0)
|
|
169
|
+
return { reaped: [], skippedYoung };
|
|
170
|
+
const dirs = new Set(doomed.map((p) => p.userDataDir));
|
|
171
|
+
const verb = opts.dryRun ? "would reap" : "reaping";
|
|
172
|
+
log(`[runner] ${verb} ${doomed.length} abandoned Playwright browser(s) (parent gone, idle >${Math.round(minAgeMs / 60000)}m):`);
|
|
173
|
+
for (const p of doomed)
|
|
174
|
+
log(`[runner] pid ${p.pid} profile ${p.userDataDir}`);
|
|
175
|
+
if (opts.dryRun)
|
|
176
|
+
return { reaped: [...dirs], skippedYoung };
|
|
177
|
+
// Ask first. The measurement says they ignore it, but a browser that CAN exit
|
|
178
|
+
// cleanly should be given the chance to flush and remove its own profile.
|
|
179
|
+
for (const p of doomed)
|
|
180
|
+
signal(p.pid, "SIGTERM");
|
|
181
|
+
const deadline = Date.now() + 2000;
|
|
182
|
+
while (Date.now() < deadline && doomed.some((p) => alive(p.pid))) {
|
|
183
|
+
// Busy-wait: this is startup, single-purpose, and bounded at 2s. A timer
|
|
184
|
+
// would need the event loop, and callers want a settled machine on return.
|
|
185
|
+
execFileSync("sleep", ["0.1"], { stdio: "ignore" });
|
|
186
|
+
}
|
|
187
|
+
for (const p of doomed)
|
|
188
|
+
if (alive(p.pid))
|
|
189
|
+
signal(p.pid, "SIGKILL");
|
|
190
|
+
// The renderer/GPU/network helpers are children of the parent we just killed and
|
|
191
|
+
// normally follow it down. Sweep any that did not, matched by the SAME profile
|
|
192
|
+
// dirs we already cleared — never by name, never by executable.
|
|
193
|
+
for (const p of listPlaywrightBrowsers()) {
|
|
194
|
+
if (dirs.has(p.userDataDir))
|
|
195
|
+
signal(p.pid, "SIGKILL");
|
|
196
|
+
}
|
|
197
|
+
for (const dir of dirs) {
|
|
198
|
+
try {
|
|
199
|
+
rmSync(dir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// a leftover directory is untidy, not harmful
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { reaped: [...dirs], skippedYoung };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Delete Playwright temp profile directories with no live process behind them.
|
|
209
|
+
*
|
|
210
|
+
* Separate from the process reap because the two leak independently: a browser
|
|
211
|
+
* that IS killed by `kill -9` leaves its directory behind with nobody to remove
|
|
212
|
+
* it. These are a few hundred MB each once they have been used.
|
|
213
|
+
*/
|
|
214
|
+
export function sweepStalePlaywrightProfiles(opts = {}) {
|
|
215
|
+
if (process.platform === "win32")
|
|
216
|
+
return [];
|
|
217
|
+
const now = opts.now ?? Date.now();
|
|
218
|
+
const minAgeMs = opts.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
|
219
|
+
const inUse = new Set(listPlaywrightBrowsers().map((p) => p.userDataDir));
|
|
220
|
+
const removed = [];
|
|
221
|
+
for (const root of tempRoots()) {
|
|
222
|
+
let entries;
|
|
223
|
+
try {
|
|
224
|
+
entries = readdirSync(root);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
for (const name of entries) {
|
|
230
|
+
if (!TEMP_PROFILE_NAME.test(name))
|
|
231
|
+
continue;
|
|
232
|
+
const dir = join(root, name);
|
|
233
|
+
if (inUse.has(dir))
|
|
234
|
+
continue;
|
|
235
|
+
if (profileIdleMs(dir, now) < minAgeMs)
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
removed.push(dir);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// best effort
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (removed.length > 0) {
|
|
247
|
+
(opts.log ?? ((l) => console.warn(l)))(`[runner] removed ${removed.length} stale Playwright temp profile dir(s)`);
|
|
248
|
+
}
|
|
249
|
+
return removed;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The startup entry point: recover a machine that has already leaked.
|
|
253
|
+
*
|
|
254
|
+
* On by default. Default-off would mean the users who most need it — the ones
|
|
255
|
+
* whose machine is already at load 253 — never get it, and the discriminator is
|
|
256
|
+
* narrow enough (see SAFETY) that a false positive would require a real browser
|
|
257
|
+
* to be running out of a Playwright mkdtemp directory, orphaned, and idle.
|
|
258
|
+
* PAGS_RUNNER_REAP=0 disable entirely
|
|
259
|
+
* PAGS_RUNNER_REAP_DRY_RUN=1 report what it would kill, kill nothing
|
|
260
|
+
* PAGS_RUNNER_REAP_MIN_AGE_MIN idle threshold in minutes (default 10)
|
|
261
|
+
*/
|
|
262
|
+
export function reapOnStartup(log = (l) => console.warn(l)) {
|
|
263
|
+
if (process.env.PAGS_RUNNER_REAP === "0")
|
|
264
|
+
return;
|
|
265
|
+
const minutes = Number(process.env.PAGS_RUNNER_REAP_MIN_AGE_MIN);
|
|
266
|
+
const minAgeMs = Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : DEFAULT_MIN_AGE_MS;
|
|
267
|
+
const dryRun = process.env.PAGS_RUNNER_REAP_DRY_RUN === "1";
|
|
268
|
+
try {
|
|
269
|
+
const { reaped, skippedYoung } = reapOrphanedPlaywrightBrowsers({ minAgeMs, dryRun, log });
|
|
270
|
+
if (skippedYoung > 0)
|
|
271
|
+
log(`[runner] left ${skippedYoung} orphaned browser(s) alone — not idle long enough yet`);
|
|
272
|
+
if (!dryRun && reaped.length > 0)
|
|
273
|
+
log(`[runner] reaped ${reaped.length} orphaned browser(s); their CPU is yours again`);
|
|
274
|
+
if (!dryRun)
|
|
275
|
+
sweepStalePlaywrightProfiles({ minAgeMs, log });
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
// Never let cleanup stop the runner from starting.
|
|
279
|
+
log(`[runner] browser reap skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -180,12 +180,26 @@ export class LocalRunner {
|
|
|
180
180
|
void this.endTakeover(id).catch(() => undefined);
|
|
181
181
|
return task;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Tear everything down, and never let one failure strand the rest (#274).
|
|
185
|
+
*
|
|
186
|
+
* `browserContext.close()` rejects routinely — the browser crashed, or was
|
|
187
|
+
* already killed. It used to reject straight out of here, which skipped the
|
|
188
|
+
* state reset AND rejected the caller's shutdown, so the process stayed up
|
|
189
|
+
* holding a live browser until someone `kill -9`ed it. That kill is exactly
|
|
190
|
+
* what orphans the browser, so an unswallowed error here MADE the leak.
|
|
191
|
+
*/
|
|
183
192
|
async close() {
|
|
184
|
-
|
|
193
|
+
try {
|
|
194
|
+
this.coding.closeAll();
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// a stuck coding session must not block the browser teardown below
|
|
198
|
+
}
|
|
185
199
|
await this.mcp?.stop().catch(() => undefined);
|
|
186
200
|
this.mcp = null;
|
|
187
201
|
this.cdpEndpoint = null;
|
|
188
|
-
await this.browserContext?.close();
|
|
202
|
+
await this.browserContext?.close().catch(() => undefined);
|
|
189
203
|
this.browserContext = null;
|
|
190
204
|
this.launchedProfileDir = null;
|
|
191
205
|
}
|