@aexol/spectral 0.9.212 → 0.9.215
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.
- package/dist/backend/generated-images.d.ts +90 -0
- package/dist/backend/generated-images.d.ts.map +1 -0
- package/dist/backend/generated-images.js +214 -0
- package/dist/backend/machine-routing.d.ts +25 -0
- package/dist/backend/machine-routing.d.ts.map +1 -0
- package/dist/backend/machine-routing.js +35 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +20 -2
- package/dist/extensions/browser/browser-service.d.ts +75 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +600 -34
- package/dist/extensions/browser/index.d.ts +10 -4
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +44 -5
- package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
- package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
- package/dist/extensions/browser/stream/stream-tool.js +42 -14
- package/dist/extensions/browser/tools/core.d.ts.map +1 -1
- package/dist/extensions/browser/tools/core.js +63 -2
- package/dist/extensions/desktop-control/index.d.ts +55 -0
- package/dist/extensions/desktop-control/index.d.ts.map +1 -0
- package/dist/extensions/desktop-control/index.js +633 -0
- package/dist/extensions/desktop-control/keys.d.ts +33 -0
- package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
- package/dist/extensions/desktop-control/keys.js +170 -0
- package/dist/extensions/desktop-control/linux.d.ts +26 -0
- package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
- package/dist/extensions/desktop-control/linux.js +195 -0
- package/dist/extensions/desktop-control/macos.d.ts +53 -0
- package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
- package/dist/extensions/desktop-control/macos.js +382 -0
- package/dist/extensions/desktop-control/safety.d.ts +89 -0
- package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
- package/dist/extensions/desktop-control/safety.js +157 -0
- package/dist/extensions/desktop-control/types.d.ts +79 -0
- package/dist/extensions/desktop-control/types.d.ts.map +1 -0
- package/dist/extensions/desktop-control/types.js +10 -0
- package/dist/extensions/desktop-control/windows.d.ts +31 -0
- package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
- package/dist/extensions/desktop-control/windows.js +276 -0
- package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
- package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
- package/dist/extensions/desktop-screenshot/index.js +363 -17
- package/dist/extensions/image-generation/index.d.ts.map +1 -1
- package/dist/extensions/image-generation/index.js +133 -34
- package/dist/extensions/seo/index.d.ts.map +1 -1
- package/dist/extensions/seo/index.js +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
- package/dist/extensions/seo/tools/backend-bridge.js +378 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/full-audit.js +1 -1
- package/dist/extensions/seo/tools/report.d.ts +10 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -1
- package/dist/extensions/seo/tools/report.js +1 -1
- package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
- package/dist/extensions/spectral-vision-fallback.js +11 -1
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +121 -4
- package/dist/generated/zeus/index.d.ts +527 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.js +5 -2
- package/dist/mcp/types.d.ts +5 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/relay/dispatcher.d.ts +12 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +44 -42
- package/dist/sdk/ai/providers/faux.js +1 -1
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +57 -16
- package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openrouter-images.js +45 -9
- package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
- package/dist/sdk/ai/providers/simple-options.js +2 -0
- package/dist/sdk/ai/types.d.ts +37 -1
- package/dist/sdk/ai/types.d.ts.map +1 -1
- package/dist/sdk/ai/utils/image-source.d.ts +121 -0
- package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
- package/dist/sdk/ai/utils/image-source.js +410 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
- package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
- package/dist/server/agent-bridge.d.ts +14 -0
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +105 -26
- package/dist/server/handlers/queue.d.ts.map +1 -1
- package/dist/server/handlers/queue.js +11 -34
- package/dist/server/image-attachments.d.ts +109 -0
- package/dist/server/image-attachments.d.ts.map +1 -0
- package/dist/server/image-attachments.js +195 -0
- package/dist/server/session-stream.d.ts +67 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +222 -23
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +10 -31
- package/dist/server/ui/terminal-confirm.d.ts +145 -0
- package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
- package/dist/server/ui/terminal-confirm.js +333 -0
- package/dist/server/wire.d.ts +30 -5
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,210 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
2
4
|
import { registerSessionResourceCleanup } from "../../sdk/ai/session-resources.js";
|
|
3
5
|
import { getBrowserConfig } from "./config.js";
|
|
4
6
|
const _require = createRequire(import.meta.url);
|
|
7
|
+
/**
|
|
8
|
+
* Unique switch added to every browser we launch. Playwright's `Browser` does
|
|
9
|
+
* not expose its child process (`process()` only exists on BrowserServer), so
|
|
10
|
+
* this marker lets us find the exact Chromium process tree we spawned (and
|
|
11
|
+
* nothing else) with `ps` when we have to SIGKILL it.
|
|
12
|
+
*/
|
|
13
|
+
const BROWSER_MARKER = "--spectral-browser-token=";
|
|
14
|
+
/** `ps -eo pid,ppid,args` easily exceeds execSync's 1 MB default (ENOBUFS). */
|
|
15
|
+
const PS_MAX_BUFFER = 32 * 1024 * 1024;
|
|
16
|
+
/**
|
|
17
|
+
* How long a launch-time marker verification stays trustworthy on platforms
|
|
18
|
+
* that cannot re-verify synchronously (no `/proc`, i.e. macOS/Windows). Past
|
|
19
|
+
* that point the exit handler skips the SIGKILL: an orphaned browser window is
|
|
20
|
+
* annoying, SIGKILLing an unrelated process that reused the pid is not.
|
|
21
|
+
*/
|
|
22
|
+
const PID_VERIFY_TTL_MS = 15 * 60 * 1000;
|
|
23
|
+
function newBrowserToken() {
|
|
24
|
+
return `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
|
+
}
|
|
26
|
+
/** Windows has no `ps`; probed once and cached. */
|
|
27
|
+
let psAvailable = null;
|
|
28
|
+
let psWarned = false;
|
|
29
|
+
function hasPs() {
|
|
30
|
+
if (psAvailable !== null)
|
|
31
|
+
return psAvailable;
|
|
32
|
+
try {
|
|
33
|
+
execSync(`ps -o pid= -p ${process.pid}`, {
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
maxBuffer: 64 * 1024,
|
|
37
|
+
});
|
|
38
|
+
psAvailable = true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
psAvailable = false;
|
|
42
|
+
process.stderr.write("[browser] `ps` is unavailable on this platform — browser processes cannot be located by marker; only graceful close is possible.\n");
|
|
43
|
+
}
|
|
44
|
+
return psAvailable;
|
|
45
|
+
}
|
|
46
|
+
function warnPsFailure(err) {
|
|
47
|
+
if (psWarned)
|
|
48
|
+
return;
|
|
49
|
+
psWarned = true;
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
process.stderr.write(`[browser] Could not read the process table (${msg}).\n`);
|
|
52
|
+
}
|
|
53
|
+
/** Never SIGKILL init/pid 1, ourselves, our parent or garbage pids. */
|
|
54
|
+
function isKillablePid(pid) {
|
|
55
|
+
return (typeof pid === "number" &&
|
|
56
|
+
Number.isFinite(pid) &&
|
|
57
|
+
pid > 1 &&
|
|
58
|
+
pid !== process.pid &&
|
|
59
|
+
pid !== process.ppid);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Identity check performed immediately before a SIGKILL: confirms the pid still
|
|
63
|
+
* runs *our* browser (pid reuse would otherwise make us kill an unrelated
|
|
64
|
+
* process). Without `ps` we cannot verify and fall back to a best-effort kill.
|
|
65
|
+
*/
|
|
66
|
+
function pidHasMarker(pid, marker) {
|
|
67
|
+
if (!hasPs())
|
|
68
|
+
return true;
|
|
69
|
+
try {
|
|
70
|
+
const out = execSync(`ps -o args= -p ${pid}`, {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
73
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
74
|
+
});
|
|
75
|
+
return out.includes(marker);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// ESRCH (gone) or EPERM — in both cases we must not signal it.
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Liveness probe only — no subprocess, no signal delivered. Used to skip the
|
|
84
|
+
* (expensive) marker lookups for pids that are already gone, which is the
|
|
85
|
+
* normal case after a graceful `browser.close()`.
|
|
86
|
+
*/
|
|
87
|
+
function pidAlive(pid) {
|
|
88
|
+
if (!isKillablePid(pid))
|
|
89
|
+
return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Marker verification without `execSync`, so it is usable from an `exit`
|
|
100
|
+
* handler: reads `/proc/<pid>/cmdline` (Linux). Returns `null` when the
|
|
101
|
+
* platform cannot answer synchronously (no `/proc`) — the caller then decides
|
|
102
|
+
* whether the launch-time evidence is still good enough.
|
|
103
|
+
*/
|
|
104
|
+
let procfsAvailable = null;
|
|
105
|
+
function hasProcfs() {
|
|
106
|
+
if (procfsAvailable !== null)
|
|
107
|
+
return procfsAvailable;
|
|
108
|
+
try {
|
|
109
|
+
procfsAvailable = readFileSync("/proc/self/cmdline", "utf8").length > 0;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
procfsAvailable = false;
|
|
113
|
+
}
|
|
114
|
+
return procfsAvailable;
|
|
115
|
+
}
|
|
116
|
+
function pidHasMarkerSync(pid, marker) {
|
|
117
|
+
if (!hasProcfs())
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
// argv is NUL-separated there; a substring match is enough for our marker.
|
|
121
|
+
return readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(marker);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ENOENT/ESRCH/EPERM — never signal what we cannot confirm is ours.
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* SIGKILL `pid` if it is still alive. Never throws and never spawns a child
|
|
130
|
+
* process, so it is safe from `process.on("exit")` / signal handlers.
|
|
131
|
+
*/
|
|
132
|
+
function killPidSync(pid) {
|
|
133
|
+
if (!pidAlive(pid))
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, "SIGKILL");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* it died between the probe and the kill — nothing to do */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** SIGKILL `pid` if it is alive and (when `marker` is given) still ours. */
|
|
143
|
+
function killPid(pid, marker) {
|
|
144
|
+
// Free probe first: a dead pid needs no `ps` lookup at all.
|
|
145
|
+
if (!pidAlive(pid))
|
|
146
|
+
return;
|
|
147
|
+
if (marker && !pidHasMarker(pid, marker))
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, "SIGKILL");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* it died between the probe and the kill — nothing to do */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Snapshot of the process table. Returns [] (and never throws) when `ps` is missing. */
|
|
157
|
+
function psRows() {
|
|
158
|
+
if (!hasPs())
|
|
159
|
+
return [];
|
|
160
|
+
let out = "";
|
|
161
|
+
try {
|
|
162
|
+
out = execSync("ps -eo pid,ppid,args", {
|
|
163
|
+
encoding: "utf8",
|
|
164
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
165
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
166
|
+
timeout: 10_000,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
warnPsFailure(err);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const rows = [];
|
|
174
|
+
for (const line of out.split("\n")) {
|
|
175
|
+
const trimmed = line.trim();
|
|
176
|
+
if (!trimmed)
|
|
177
|
+
continue;
|
|
178
|
+
// First column must be a pid — skips the header row and wrapped lines.
|
|
179
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
180
|
+
if (firstSpace <= 0)
|
|
181
|
+
continue;
|
|
182
|
+
const pid = Number.parseInt(trimmed.slice(0, firstSpace), 10);
|
|
183
|
+
if (!Number.isFinite(pid))
|
|
184
|
+
continue;
|
|
185
|
+
const rest = trimmed.slice(firstSpace + 1).trimStart();
|
|
186
|
+
const secondSpace = rest.indexOf(" ");
|
|
187
|
+
if (secondSpace <= 0)
|
|
188
|
+
continue;
|
|
189
|
+
const ppid = Number.parseInt(rest.slice(0, secondSpace), 10);
|
|
190
|
+
if (!Number.isFinite(ppid))
|
|
191
|
+
continue;
|
|
192
|
+
rows.push({ pid, ppid, args: rest.slice(secondSpace + 1) });
|
|
193
|
+
}
|
|
194
|
+
return rows;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Argv for a launch. Chromium ignores unknown switches, so the marker is
|
|
198
|
+
* harmless there; Firefox/WebKit parse argv strictly and may refuse to start on
|
|
199
|
+
* an unknown switch, so they get plain args (they can then only be killed
|
|
200
|
+
* through graceful close or a pid exposed by `browser.process()`).
|
|
201
|
+
*/
|
|
202
|
+
function launchArgsFor(browserName, token) {
|
|
203
|
+
const base = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"];
|
|
204
|
+
if (browserName !== "chromium" || !token)
|
|
205
|
+
return base;
|
|
206
|
+
return [`${BROWSER_MARKER}${token}`, ...base];
|
|
207
|
+
}
|
|
5
208
|
let _pw = null;
|
|
6
209
|
let _pwError = null;
|
|
7
210
|
let _preflightResult = null;
|
|
@@ -28,6 +231,95 @@ let unregisterInstanceCleanup = null;
|
|
|
28
231
|
// cannot itself await (it is invoked synchronously from session.dispose()).
|
|
29
232
|
let pendingClose = null;
|
|
30
233
|
let closeAllPromise = null;
|
|
234
|
+
function isBrowserConnected(browser) {
|
|
235
|
+
if (!browser)
|
|
236
|
+
return false;
|
|
237
|
+
try {
|
|
238
|
+
return browser.isConnected();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* All live owners, not a single slot: two BrowserService instances (e.g. after
|
|
246
|
+
* resetInstance()) must both be cleaned up, otherwise the older browser is left
|
|
247
|
+
* orphaned when the newer one detaches the shared handler.
|
|
248
|
+
*/
|
|
249
|
+
const processCleanups = new Set();
|
|
250
|
+
let cleanupHandlersAttached = false;
|
|
251
|
+
let cleanupOnExit = null;
|
|
252
|
+
let cleanupOnSignal = null;
|
|
253
|
+
/**
|
|
254
|
+
* Safety net: synchronously SIGKILL the browser process if the host process
|
|
255
|
+
* exits (or is interrupted) while a browser is still attached — otherwise the
|
|
256
|
+
* Chromium window survives the CLI. Only one set of handlers is ever attached
|
|
257
|
+
* (shared by every owner) and it is detached again once the last owner is
|
|
258
|
+
* released, so no listeners accumulate.
|
|
259
|
+
*/
|
|
260
|
+
function attachProcessCleanup(owner, kill) {
|
|
261
|
+
for (const existing of processCleanups) {
|
|
262
|
+
if (existing.owner === owner) {
|
|
263
|
+
existing.kill = kill;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
processCleanups.add({ owner, kill });
|
|
268
|
+
if (cleanupHandlersAttached)
|
|
269
|
+
return;
|
|
270
|
+
cleanupHandlersAttached = true;
|
|
271
|
+
cleanupOnExit = () => {
|
|
272
|
+
runProcessCleanup();
|
|
273
|
+
};
|
|
274
|
+
cleanupOnSignal = (sig) => {
|
|
275
|
+
runProcessCleanup();
|
|
276
|
+
// Never swallow the signal: if nothing else handles it, re-raise it so the
|
|
277
|
+
// process still terminates exactly like it would without our handler.
|
|
278
|
+
if (process.listenerCount(sig) === 0) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(process.pid, sig);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* ignore */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
process.once("exit", cleanupOnExit);
|
|
288
|
+
process.once("SIGINT", cleanupOnSignal);
|
|
289
|
+
process.once("SIGTERM", cleanupOnSignal);
|
|
290
|
+
}
|
|
291
|
+
function runProcessCleanup() {
|
|
292
|
+
for (const entry of [...processCleanups]) {
|
|
293
|
+
try {
|
|
294
|
+
entry.kill();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* never let cleanup throw */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function releaseProcessCleanup(owner) {
|
|
302
|
+
if (owner) {
|
|
303
|
+
for (const entry of [...processCleanups]) {
|
|
304
|
+
if (entry.owner === owner)
|
|
305
|
+
processCleanups.delete(entry);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
processCleanups.clear();
|
|
310
|
+
}
|
|
311
|
+
if (processCleanups.size > 0 || !cleanupHandlersAttached)
|
|
312
|
+
return;
|
|
313
|
+
if (cleanupOnExit)
|
|
314
|
+
process.off("exit", cleanupOnExit);
|
|
315
|
+
if (cleanupOnSignal) {
|
|
316
|
+
process.off("SIGINT", cleanupOnSignal);
|
|
317
|
+
process.off("SIGTERM", cleanupOnSignal);
|
|
318
|
+
}
|
|
319
|
+
cleanupHandlersAttached = false;
|
|
320
|
+
cleanupOnExit = null;
|
|
321
|
+
cleanupOnSignal = null;
|
|
322
|
+
}
|
|
31
323
|
export class BrowserService {
|
|
32
324
|
browser = null;
|
|
33
325
|
context = null;
|
|
@@ -41,6 +333,33 @@ export class BrowserService {
|
|
|
41
333
|
_recording = null;
|
|
42
334
|
closePromise = null;
|
|
43
335
|
launchLock = null;
|
|
336
|
+
/** Unique marker identifying the process tree of the current launch. */
|
|
337
|
+
launchToken = null;
|
|
338
|
+
/**
|
|
339
|
+
* Token of the current *or previous* launch — only cleared by the next
|
|
340
|
+
* launch. Post-close fallbacks (`forceKillBrowserProcess()` / `browser_quit`)
|
|
341
|
+
* still need it to find the process tree after `close()` nulled launchToken.
|
|
342
|
+
* Every kill through this token is marker-verified first, so a recycled pid
|
|
343
|
+
* is never signalled.
|
|
344
|
+
*/
|
|
345
|
+
lastLaunchToken = null;
|
|
346
|
+
/**
|
|
347
|
+
* Root pid + full marker-matched tree, resolved once after a successful
|
|
348
|
+
* launch (single `ps` call) so exit/signal handlers can SIGKILL without
|
|
349
|
+
* running `execSync` from inside the handler.
|
|
350
|
+
*/
|
|
351
|
+
browserPid = null;
|
|
352
|
+
browserPids = [];
|
|
353
|
+
/**
|
|
354
|
+
* Evidence for the synchronous teardown path: the pids that were matched by
|
|
355
|
+
* *our* marker at launch time, when they were matched and under which token.
|
|
356
|
+
* `killBrowserProcessSync()` may only signal pids listed here — a pid that we
|
|
357
|
+
* never marker-verified (or that no longer carries the marker) can belong to
|
|
358
|
+
* somebody else by now.
|
|
359
|
+
*/
|
|
360
|
+
verifiedBrowserPids = [];
|
|
361
|
+
verifiedBrowserPidsAt = 0;
|
|
362
|
+
verifiedBrowserPidsToken = null;
|
|
44
363
|
constructor(config) {
|
|
45
364
|
this.config = { ...getBrowserConfig(), ...config };
|
|
46
365
|
}
|
|
@@ -71,11 +390,24 @@ export class BrowserService {
|
|
|
71
390
|
static resetInstance() {
|
|
72
391
|
unregisterInstanceCleanup?.();
|
|
73
392
|
unregisterInstanceCleanup = null;
|
|
393
|
+
const previous = instance;
|
|
74
394
|
instance = null;
|
|
75
395
|
pendingClose = null;
|
|
76
396
|
closeAllPromise = null;
|
|
77
397
|
_preflightResult = null;
|
|
78
398
|
_pwError = null;
|
|
399
|
+
// Never orphan the browser owned by the discarded instance: SIGKILL its
|
|
400
|
+
// process tree and detach its process handlers synchronously (the old
|
|
401
|
+
// instance is unreachable afterwards, so graceful close would never run).
|
|
402
|
+
if (previous) {
|
|
403
|
+
try {
|
|
404
|
+
previous.killBrowserProcess();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* never let cleanup throw */
|
|
408
|
+
}
|
|
409
|
+
releaseProcessCleanup(previous);
|
|
410
|
+
}
|
|
79
411
|
}
|
|
80
412
|
/**
|
|
81
413
|
* Close the active browser singleton (if any) and await completion. Unlike
|
|
@@ -162,13 +494,11 @@ export class BrowserService {
|
|
|
162
494
|
const doLaunch = async () => {
|
|
163
495
|
await this.close();
|
|
164
496
|
const pw = loadPlaywright();
|
|
497
|
+
this.launchToken = newBrowserToken();
|
|
498
|
+
this.lastLaunchToken = this.launchToken;
|
|
165
499
|
const launchOptions = {
|
|
166
500
|
headless: this.config.headless,
|
|
167
|
-
args:
|
|
168
|
-
"--no-sandbox",
|
|
169
|
-
"--disable-setuid-sandbox",
|
|
170
|
-
"--disable-dev-shm-usage",
|
|
171
|
-
],
|
|
501
|
+
args: launchArgsFor(this.config.browserName, this.launchToken),
|
|
172
502
|
};
|
|
173
503
|
switch (this.config.browserName) {
|
|
174
504
|
case "firefox":
|
|
@@ -180,6 +510,10 @@ export class BrowserService {
|
|
|
180
510
|
default:
|
|
181
511
|
this.browser = await pw.chromium.launch(launchOptions);
|
|
182
512
|
}
|
|
513
|
+
// Guarantee the browser process is killed even if the host process dies
|
|
514
|
+
// before close() ever runs.
|
|
515
|
+
this.resolveBrowserPids(this.launchToken);
|
|
516
|
+
attachProcessCleanup(this, () => this.killBrowserProcessSync());
|
|
183
517
|
this.context = await this.browser.newContext({
|
|
184
518
|
viewport: this.config.viewport,
|
|
185
519
|
});
|
|
@@ -241,16 +575,51 @@ export class BrowserService {
|
|
|
241
575
|
return { url: page.url(), title: await page.title() };
|
|
242
576
|
}
|
|
243
577
|
async closePage() {
|
|
578
|
+
const openBlankPage = async () => {
|
|
579
|
+
if (!this.context) {
|
|
580
|
+
this.pages = [];
|
|
581
|
+
this.activePageIndex = 0;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
const newPage = await this.context.newPage();
|
|
586
|
+
this.trackNetworkRequest(newPage);
|
|
587
|
+
this.installDialogInterceptor(newPage);
|
|
588
|
+
this.pages = [newPage];
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
this.pages = [];
|
|
592
|
+
}
|
|
593
|
+
this.activePageIndex = 0;
|
|
594
|
+
};
|
|
595
|
+
// Nothing is open — do not blow up with "cannot read property close of
|
|
596
|
+
// undefined"; just make sure a blank page is available again.
|
|
597
|
+
if (this.pages.length === 0) {
|
|
598
|
+
await openBlankPage();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (this.activePageIndex < 0 || this.activePageIndex >= this.pages.length) {
|
|
602
|
+
this.activePageIndex = 0;
|
|
603
|
+
}
|
|
244
604
|
const page = this.pages[this.activePageIndex];
|
|
245
|
-
|
|
605
|
+
if (!page) {
|
|
606
|
+
await openBlankPage();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
await page.close();
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
// The page/window may already be gone (closed by the user or the tab
|
|
614
|
+
// crashed) — keep cleaning up instead of surfacing a confusing error.
|
|
615
|
+
process.stderr.write(`[browser] closePage: ${e.message}\n`);
|
|
616
|
+
}
|
|
246
617
|
this.pages.splice(this.activePageIndex, 1);
|
|
247
|
-
if (this.pages.length === 0
|
|
248
|
-
|
|
249
|
-
this.pages = [newPage];
|
|
250
|
-
this.activePageIndex = 0;
|
|
618
|
+
if (this.pages.length === 0) {
|
|
619
|
+
await openBlankPage();
|
|
251
620
|
}
|
|
252
621
|
else {
|
|
253
|
-
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
|
|
622
|
+
this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, this.pages.length - 1));
|
|
254
623
|
}
|
|
255
624
|
}
|
|
256
625
|
async resize(width, height) {
|
|
@@ -378,39 +747,224 @@ export class BrowserService {
|
|
|
378
747
|
if (this.closePromise)
|
|
379
748
|
return this.closePromise;
|
|
380
749
|
this.closePromise = (async () => {
|
|
381
|
-
const withTimeout = async (promise, ms, label) => {
|
|
750
|
+
const withTimeout = async (promise, ms, label, onTimeout) => {
|
|
751
|
+
let timer;
|
|
382
752
|
try {
|
|
383
753
|
return await Promise.race([
|
|
384
754
|
promise,
|
|
385
|
-
new Promise((_, reject) =>
|
|
755
|
+
new Promise((_, reject) => {
|
|
756
|
+
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms: ${label}`)), ms);
|
|
757
|
+
})
|
|
386
758
|
]);
|
|
387
759
|
}
|
|
388
760
|
catch (e) {
|
|
389
|
-
|
|
761
|
+
const msg = e.message ?? "";
|
|
762
|
+
// A timeout is a failed close too: browser.close() is still hanging
|
|
763
|
+
// and the process must be SIGKILLed, exactly like a rejection.
|
|
764
|
+
if (msg.startsWith("Timed out after"))
|
|
765
|
+
onTimeout?.();
|
|
766
|
+
process.stderr.write(`[browser] close ${label}: ${msg}\n`);
|
|
767
|
+
}
|
|
768
|
+
finally {
|
|
769
|
+
if (timer)
|
|
770
|
+
clearTimeout(timer);
|
|
390
771
|
}
|
|
391
772
|
};
|
|
392
773
|
const closeTimeout = 5000; // 5 seconds per operation
|
|
393
|
-
|
|
394
|
-
await withTimeout(page.close().catch(() => { }), closeTimeout, 'page.close()');
|
|
395
|
-
}
|
|
396
|
-
this.pages = [];
|
|
397
|
-
this.activePageIndex = 0;
|
|
774
|
+
const browser = this.browser;
|
|
398
775
|
const context = this.context;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
776
|
+
let browserCloseFailed = false;
|
|
777
|
+
try {
|
|
778
|
+
for (const page of [...this.pages]) {
|
|
779
|
+
await withTimeout(page.close().catch(() => { }), closeTimeout, 'page.close()');
|
|
780
|
+
}
|
|
781
|
+
this.pages = [];
|
|
782
|
+
this.activePageIndex = 0;
|
|
783
|
+
this.context = null;
|
|
784
|
+
if (context) {
|
|
785
|
+
await withTimeout(context.close().catch(() => { }), closeTimeout, 'context.close()');
|
|
786
|
+
}
|
|
787
|
+
this.browser = null;
|
|
788
|
+
if (browser) {
|
|
789
|
+
await withTimeout(browser.close().catch((e) => {
|
|
790
|
+
browserCloseFailed = true;
|
|
791
|
+
process.stderr.write(`[browser] close browser.close(): ${e.message}\n`);
|
|
792
|
+
}), closeTimeout, 'browser.close()', () => {
|
|
793
|
+
browserCloseFailed = true;
|
|
794
|
+
});
|
|
795
|
+
}
|
|
402
796
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
797
|
+
finally {
|
|
798
|
+
// browser.close() may have thrown, timed out or been abandoned — in
|
|
799
|
+
// that case the Chromium process is still alive and would be left
|
|
800
|
+
// orphaned, so SIGKILL it directly. A known launch token alone is not
|
|
801
|
+
// evidence of a live process, but the browser object may still expose
|
|
802
|
+
// one, so the kill is still attempted — just without the expensive
|
|
803
|
+
// process-table scan (see `scanProcessTable`).
|
|
804
|
+
const token = this.launchToken ?? this.lastLaunchToken;
|
|
805
|
+
const hasKnownPids = this.browserPid !== null || this.browserPids.length > 0;
|
|
806
|
+
// Anything in here means "there may still be a browser process".
|
|
807
|
+
const mayStillBeAlive = browserCloseFailed || isBrowserConnected(browser) || hasKnownPids;
|
|
808
|
+
if (browser && (mayStillBeAlive || token)) {
|
|
809
|
+
this.killBrowserProcess(browser, { scanProcessTable: mayStillBeAlive });
|
|
810
|
+
}
|
|
811
|
+
// Graceful close confirmed: forget the resolved pids so a later exit
|
|
812
|
+
// handler can never SIGKILL a recycled pid.
|
|
813
|
+
if (!browserCloseFailed && !isBrowserConnected(browser)) {
|
|
814
|
+
this.browserPid = null;
|
|
815
|
+
this.browserPids = [];
|
|
816
|
+
this.verifiedBrowserPids = [];
|
|
817
|
+
this.verifiedBrowserPidsAt = 0;
|
|
818
|
+
this.verifiedBrowserPidsToken = null;
|
|
819
|
+
}
|
|
820
|
+
releaseProcessCleanup(this);
|
|
821
|
+
this.pages = [];
|
|
822
|
+
this.activePageIndex = 0;
|
|
823
|
+
this.context = null;
|
|
824
|
+
this.browser = null;
|
|
825
|
+
this.launchToken = null;
|
|
826
|
+
// lastLaunchToken is intentionally kept (see field docs).
|
|
827
|
+
this._recording = null;
|
|
407
828
|
}
|
|
408
|
-
this._recording = null;
|
|
409
829
|
})().finally(() => {
|
|
410
830
|
this.closePromise = null;
|
|
411
831
|
});
|
|
412
832
|
return this.closePromise;
|
|
413
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Last-resort teardown used by the `browser_quit` tool: SIGKILL the browser
|
|
836
|
+
* process without waiting for a graceful close. Safe to call at any time.
|
|
837
|
+
*/
|
|
838
|
+
forceKillBrowserProcess() {
|
|
839
|
+
this.killBrowserProcess();
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* SIGKILL the browser process behind `browser` (defaults to the live one).
|
|
843
|
+
* `browser.close()` can hang, be rejected or be abandoned while the host
|
|
844
|
+
* process is shutting down, which leaves a visible Chromium window behind.
|
|
845
|
+
*
|
|
846
|
+
* `opts.scanProcessTable === false` skips `findBrowserPids()` (a `ps -eo`
|
|
847
|
+
* fork) — use it when nothing indicates a live browser. Only pids we already
|
|
848
|
+
* know about (or the one `browser.process()` exposes) are then signalled,
|
|
849
|
+
* each still marker-verified before the SIGKILL.
|
|
850
|
+
*/
|
|
851
|
+
killBrowserProcess(browser = this.browser, opts) {
|
|
852
|
+
try {
|
|
853
|
+
const token = this.launchToken ?? this.lastLaunchToken;
|
|
854
|
+
const marker = token ? `${BROWSER_MARKER}${token}` : undefined;
|
|
855
|
+
// `process()` only exists on BrowserServer/ElectronApplication, but keep
|
|
856
|
+
// using it whenever it is available.
|
|
857
|
+
const proc = browser
|
|
858
|
+
?.process?.();
|
|
859
|
+
const directPid = proc?.pid ?? null;
|
|
860
|
+
// Locate the whole tree via the unique marker we passed to launch() —
|
|
861
|
+
// children (renderer/GPU/utility) inherit the switch, so they die too.
|
|
862
|
+
// Never kill unrelated Chrome instances: killPid() re-checks the marker.
|
|
863
|
+
const targets = new Set(this.browserPids);
|
|
864
|
+
if (opts?.scanProcessTable !== false) {
|
|
865
|
+
for (const found of this.findBrowserPids())
|
|
866
|
+
targets.add(found);
|
|
867
|
+
}
|
|
868
|
+
const root = directPid ?? this.browserPid;
|
|
869
|
+
if (root)
|
|
870
|
+
targets.add(root);
|
|
871
|
+
// Children first, root last: the root cannot respawn anything after it
|
|
872
|
+
// receives SIGKILL.
|
|
873
|
+
const ordered = [...targets].sort((a, b) => Number(b === root) - Number(a === root));
|
|
874
|
+
for (const pid of ordered)
|
|
875
|
+
killPid(pid, marker);
|
|
876
|
+
this.browserPid = null;
|
|
877
|
+
this.browserPids = [];
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* never let cleanup throw */
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Exit/signal-handler path: SIGKILL the pids resolved right after launch.
|
|
885
|
+
* Deliberately execSync-free — spawning `ps` from inside an `exit` handler is
|
|
886
|
+
* unreliable (and can silently no-op).
|
|
887
|
+
*
|
|
888
|
+
* Never signals a pid that may not be ours: only pids whose marker was
|
|
889
|
+
* verified at launch time are eligible, and each candidate still has to pass
|
|
890
|
+
* a synchronous identity check — a `process.kill(pid, 0)` probe plus, where
|
|
891
|
+
* the platform allows it, a `/proc/<pid>/cmdline` read. Where no synchronous
|
|
892
|
+
* verification exists (no `/proc`), the launch-time evidence is trusted only
|
|
893
|
+
* while it is fresh (`PID_VERIFY_TTL_MS`): an orphaned browser window is
|
|
894
|
+
* preferable to SIGKILLing a recycled pid that now belongs to someone else.
|
|
895
|
+
*/
|
|
896
|
+
killBrowserProcessSync() {
|
|
897
|
+
try {
|
|
898
|
+
const verified = new Set(this.verifiedBrowserPids);
|
|
899
|
+
if (verified.size === 0)
|
|
900
|
+
return;
|
|
901
|
+
const token = this.verifiedBrowserPidsToken ?? this.launchToken ?? this.lastLaunchToken;
|
|
902
|
+
if (!token)
|
|
903
|
+
return;
|
|
904
|
+
const marker = `${BROWSER_MARKER}${token}`;
|
|
905
|
+
const fresh = this.verifiedBrowserPidsAt > 0 &&
|
|
906
|
+
Date.now() - this.verifiedBrowserPidsAt <= PID_VERIFY_TTL_MS;
|
|
907
|
+
const pids = new Set(this.browserPids);
|
|
908
|
+
if (this.browserPid !== null)
|
|
909
|
+
pids.add(this.browserPid);
|
|
910
|
+
for (const pid of pids) {
|
|
911
|
+
// Resolved by anything other than our own marker match → hands off.
|
|
912
|
+
if (!verified.has(pid))
|
|
913
|
+
continue;
|
|
914
|
+
// Already gone: the free probe short-circuits, no subprocess at all.
|
|
915
|
+
if (!pidAlive(pid))
|
|
916
|
+
continue;
|
|
917
|
+
const stillOurs = pidHasMarkerSync(pid, marker);
|
|
918
|
+
// `/proc` answered and it is not our browser (pid reuse) → skip.
|
|
919
|
+
if (stillOurs === false)
|
|
920
|
+
continue;
|
|
921
|
+
// Cannot verify on this platform → trust only a recent verification.
|
|
922
|
+
if (stillOurs === null && !fresh)
|
|
923
|
+
continue;
|
|
924
|
+
killPidSync(pid);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
/* never let cleanup throw */
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Resolve the browser's pids once, right after a successful launch, so the
|
|
933
|
+
* synchronous teardown path never has to inspect the process table again.
|
|
934
|
+
*/
|
|
935
|
+
resolveBrowserPids(token) {
|
|
936
|
+
this.browserPid = null;
|
|
937
|
+
this.browserPids = [];
|
|
938
|
+
this.verifiedBrowserPids = [];
|
|
939
|
+
this.verifiedBrowserPidsAt = 0;
|
|
940
|
+
this.verifiedBrowserPidsToken = null;
|
|
941
|
+
if (!token)
|
|
942
|
+
return;
|
|
943
|
+
const marker = `${BROWSER_MARKER}${token}`;
|
|
944
|
+
const rows = psRows().filter((r) => r.args.includes(marker) && isKillablePid(r.pid));
|
|
945
|
+
if (rows.length === 0)
|
|
946
|
+
return;
|
|
947
|
+
this.browserPids = rows.map((r) => r.pid);
|
|
948
|
+
// The root is the child of this process; fall back to the lowest pid (ps
|
|
949
|
+
// prints ascending pids and the root is spawned first).
|
|
950
|
+
const root = rows.find((r) => r.ppid === process.pid) ?? rows[0];
|
|
951
|
+
this.browserPid = root?.pid ?? null;
|
|
952
|
+
// Evidence for killBrowserProcessSync(): these pids were matched by our own
|
|
953
|
+
// marker right after launch, so the sync path may signal them.
|
|
954
|
+
this.verifiedBrowserPids = [...this.browserPids];
|
|
955
|
+
this.verifiedBrowserPidsAt = Date.now();
|
|
956
|
+
this.verifiedBrowserPidsToken = token;
|
|
957
|
+
}
|
|
958
|
+
/** Find the pids of the browser processes we spawned (marker match). */
|
|
959
|
+
findBrowserPids() {
|
|
960
|
+
const token = this.launchToken ?? this.lastLaunchToken;
|
|
961
|
+
if (!token)
|
|
962
|
+
return [];
|
|
963
|
+
const marker = `${BROWSER_MARKER}${token}`;
|
|
964
|
+
return psRows()
|
|
965
|
+
.filter((r) => r.args.includes(marker) && isKillablePid(r.pid))
|
|
966
|
+
.map((r) => r.pid);
|
|
967
|
+
}
|
|
414
968
|
// ── Recording ──────────────────────────────────────────────
|
|
415
969
|
async startRecording(opts) {
|
|
416
970
|
if (this._recording) {
|
|
@@ -437,11 +991,13 @@ export class BrowserService {
|
|
|
437
991
|
// Create new browser+context with recording enabled
|
|
438
992
|
const pw = loadPlaywright();
|
|
439
993
|
const recordHeadless = this.config.recordHeadless;
|
|
440
|
-
const launchOpts = {
|
|
441
|
-
headless: recordHeadless,
|
|
442
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
443
|
-
};
|
|
444
994
|
if (!this.browser?.isConnected()) {
|
|
995
|
+
this.launchToken = newBrowserToken();
|
|
996
|
+
this.lastLaunchToken = this.launchToken;
|
|
997
|
+
const launchOpts = {
|
|
998
|
+
headless: recordHeadless,
|
|
999
|
+
args: launchArgsFor(this.config.browserName, this.launchToken),
|
|
1000
|
+
};
|
|
445
1001
|
switch (this.config.browserName) {
|
|
446
1002
|
case "firefox":
|
|
447
1003
|
this.browser = await pw.firefox.launch(launchOpts);
|
|
@@ -453,6 +1009,8 @@ export class BrowserService {
|
|
|
453
1009
|
this.browser = await pw.chromium.launch(launchOpts);
|
|
454
1010
|
break;
|
|
455
1011
|
}
|
|
1012
|
+
this.resolveBrowserPids(this.launchToken);
|
|
1013
|
+
attachProcessCleanup(this, () => this.killBrowserProcessSync());
|
|
456
1014
|
}
|
|
457
1015
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
458
1016
|
const base = opts?.filename
|
|
@@ -482,7 +1040,13 @@ export class BrowserService {
|
|
|
482
1040
|
this._recording = { dir, startedAt: Date.now(), outputPath, filename: opts?.filename };
|
|
483
1041
|
return { outputPath, startedAt: this._recording.startedAt };
|
|
484
1042
|
}
|
|
485
|
-
|
|
1043
|
+
/**
|
|
1044
|
+
* @param opts.relaunch Re-open a fresh non-recording context afterwards
|
|
1045
|
+
* (default true, what `browser_record_stop` needs). Pass `false` when the
|
|
1046
|
+
* session is shutting down — otherwise a brand new Chromium window is
|
|
1047
|
+
* spawned only to be closed again immediately.
|
|
1048
|
+
*/
|
|
1049
|
+
async stopRecording(opts = {}) {
|
|
486
1050
|
if (!this._recording) {
|
|
487
1051
|
throw new Error("No active recording.");
|
|
488
1052
|
}
|
|
@@ -519,8 +1083,10 @@ export class BrowserService {
|
|
|
519
1083
|
catch { /* ignore rename failure, keep real path */ }
|
|
520
1084
|
}
|
|
521
1085
|
this._recording = null;
|
|
522
|
-
// Create a fresh non-recording context for continued use
|
|
523
|
-
|
|
1086
|
+
// Create a fresh non-recording context for continued use (skipped on
|
|
1087
|
+
// shutdown — see the `relaunch` option above).
|
|
1088
|
+
if (opts.relaunch !== false)
|
|
1089
|
+
await this.ensureBrowser();
|
|
524
1090
|
return { outputPath: finalPath, durationSeconds };
|
|
525
1091
|
}
|
|
526
1092
|
getRecordStatus() {
|