opera-devtools-mcp 0.7.0 → 0.8.0
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/README.md +1 -1
- package/build/src/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- package/build/src/bin/opera-devtools.js +0 -10
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* Copyright 2026 Google LLC
|
|
5
5
|
* SPDX-License-Identifier: Apache-2.0
|
|
6
6
|
*/
|
|
7
|
-
import fs, { constants,
|
|
7
|
+
import fs, { constants, writeSync, closeSync } from 'node:fs';
|
|
8
8
|
import { createServer } from 'node:net';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import process from 'node:process';
|
|
12
|
+
import { claimPidFile } from '../opera/daemonPidFile.js';
|
|
13
|
+
import { installShutdownHandlers, recordShutdownReason, } from '../opera/daemonShutdown.js';
|
|
14
|
+
import { dispatchSocketMessage, reportStartupFailure, } from '../opera/daemonSocket.js';
|
|
15
|
+
import { attachLogForwarding } from '../opera/daemonStreaming.js';
|
|
16
|
+
import { callDaemonTool } from '../opera/daemonToolCall.js';
|
|
17
|
+
import { superviseMcpServer } from '../opera/mcpServerSupervisor.js';
|
|
12
18
|
import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
|
|
13
19
|
import { logger, puppeteerLogger } from '../utils/logger.js';
|
|
14
20
|
import { VERSION } from '../version.js';
|
|
@@ -54,16 +60,11 @@ catch (err) {
|
|
|
54
60
|
}
|
|
55
61
|
let fd = -1;
|
|
56
62
|
try {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
// - 0o600: Permissions: read/write for owner, no permissions for others.
|
|
63
|
-
fd = openSync(pidFilePath, constants.O_WRONLY |
|
|
64
|
-
constants.O_CREAT |
|
|
65
|
-
constants.O_TRUNC |
|
|
66
|
-
constants.O_NOFOLLOW, 0o600);
|
|
63
|
+
// The claim is `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` — write only,
|
|
64
|
+
// create if absent, fail if another daemon owns it, never follow a symlink —
|
|
65
|
+
// at 0o600. See `opera/daemonPidFile.ts` for why it is not upstream's
|
|
66
|
+
// `O_TRUNC`.
|
|
67
|
+
fd = claimPidFile(sessionId);
|
|
67
68
|
writeSync(fd, process.pid.toString());
|
|
68
69
|
}
|
|
69
70
|
catch (err) {
|
|
@@ -88,33 +89,54 @@ const mcpServerArgs = process.argv.slice(2);
|
|
|
88
89
|
let mcpClient = null;
|
|
89
90
|
let mcpTransport = null;
|
|
90
91
|
let server = null;
|
|
92
|
+
/** Set once `listen` succeeds, so a later failure knows it owns the socket. */
|
|
93
|
+
let bound = false;
|
|
94
|
+
/** Brings the MCP server back when its stdio child dies; see the module. */
|
|
95
|
+
const mcpSupervisor = superviseMcpServer({
|
|
96
|
+
sessionId,
|
|
97
|
+
connect: setupMCPClient,
|
|
98
|
+
handles: () => ({ client: mcpClient, transport: mcpTransport }),
|
|
99
|
+
giveUp: () => cleanup(1),
|
|
100
|
+
});
|
|
91
101
|
async function setupMCPClient() {
|
|
92
102
|
console.log('Setting up MCP client connection...');
|
|
93
103
|
// Create stdio transport for chrome-devtools-mcp
|
|
94
|
-
|
|
104
|
+
const transport = new StdioClientTransport({
|
|
95
105
|
command: process.execPath,
|
|
96
106
|
args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
|
|
97
107
|
env: process.env,
|
|
98
108
|
});
|
|
109
|
+
mcpTransport = transport;
|
|
99
110
|
mcpClient = new Client({
|
|
100
111
|
name: DAEMON_CLIENT_NAME,
|
|
101
112
|
version: VERSION,
|
|
102
113
|
}, {
|
|
103
114
|
capabilities: {},
|
|
104
115
|
});
|
|
105
|
-
|
|
116
|
+
// Set onclose BEFORE connect: the SDK's Protocol.connect() captures the
|
|
117
|
+
// transport's existing onclose and wraps it. Setting it after connect
|
|
118
|
+
// overwrites that wrapper, so _onclose() never runs and pending callTool
|
|
119
|
+
// requests hang forever when the MCP server dies. The callback carries the
|
|
120
|
+
// transport it belongs to, so a respawn can tell a server it replaced from the
|
|
121
|
+
// one it is watching.
|
|
122
|
+
transport.onclose = () => mcpSupervisor.onTransportClosed(transport);
|
|
123
|
+
await mcpClient.connect(transport);
|
|
124
|
+
// Opera AI tools stream their output as `notifications/message` chunks; the
|
|
125
|
+
// socket protocol carries them to the CLI. See `opera/daemonStreaming.ts`.
|
|
126
|
+
attachLogForwarding(mcpClient);
|
|
106
127
|
console.log('MCP client connected');
|
|
107
128
|
}
|
|
108
|
-
async function handleRequest(msg) {
|
|
129
|
+
async function handleRequest(msg, streamToken) {
|
|
109
130
|
try {
|
|
110
131
|
if (msg.method === 'invoke_tool') {
|
|
111
132
|
if (!mcpClient) {
|
|
112
133
|
throw new Error('MCP client not initialized');
|
|
113
134
|
}
|
|
114
135
|
const { tool, args } = msg;
|
|
115
|
-
const result = (await mcpClient
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
const result = (await callDaemonTool(mcpClient, {
|
|
137
|
+
tool,
|
|
138
|
+
args,
|
|
139
|
+
streamToken,
|
|
118
140
|
}));
|
|
119
141
|
return {
|
|
120
142
|
success: true,
|
|
@@ -126,7 +148,7 @@ async function handleRequest(msg) {
|
|
|
126
148
|
await started;
|
|
127
149
|
// Trigger cleanup asynchronously.
|
|
128
150
|
setImmediate(() => {
|
|
129
|
-
void cleanup();
|
|
151
|
+
void cleanup(0, 'stopped by request');
|
|
130
152
|
});
|
|
131
153
|
return {
|
|
132
154
|
success: true,
|
|
@@ -177,7 +199,7 @@ async function startSocketServer() {
|
|
|
177
199
|
const transport = new PipeTransport(socket, socket, puppeteerLogger);
|
|
178
200
|
transport.onmessage = async (message) => {
|
|
179
201
|
logger?.('onmessage', message);
|
|
180
|
-
const response = await
|
|
202
|
+
const response = await dispatchSocketMessage(message, transport, handleRequest);
|
|
181
203
|
transport.send(JSON.stringify(response));
|
|
182
204
|
socket.end();
|
|
183
205
|
};
|
|
@@ -190,6 +212,7 @@ async function startSocketServer() {
|
|
|
190
212
|
readableAll: false,
|
|
191
213
|
writableAll: false,
|
|
192
214
|
}, async () => {
|
|
215
|
+
bound = true;
|
|
193
216
|
console.log(`Daemon server listening on ${socketPath}`);
|
|
194
217
|
try {
|
|
195
218
|
// Setup MCP client
|
|
@@ -206,7 +229,14 @@ async function startSocketServer() {
|
|
|
206
229
|
});
|
|
207
230
|
});
|
|
208
231
|
}
|
|
209
|
-
async function cleanup(exitCode = 0) {
|
|
232
|
+
async function cleanup(exitCode = 0, reason) {
|
|
233
|
+
// The reason is the diagnostic, so it goes first: `recordShutdownReason` is
|
|
234
|
+
// best-effort, and nothing else in this teardown should be able to cost us it.
|
|
235
|
+
recordShutdownReason(sessionId, reason);
|
|
236
|
+
// Then stop supervising. Closing the client below closes the transport, which
|
|
237
|
+
// would otherwise land in the respawn path and spawn a replacement server
|
|
238
|
+
// while this teardown is running.
|
|
239
|
+
mcpSupervisor.stop();
|
|
210
240
|
console.log('Cleaning up daemon...');
|
|
211
241
|
try {
|
|
212
242
|
await mcpClient?.close();
|
|
@@ -239,28 +269,21 @@ async function cleanup(exitCode = 0) {
|
|
|
239
269
|
}
|
|
240
270
|
process.exit(exitCode);
|
|
241
271
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
process.on('SIGHUP', () => {
|
|
250
|
-
void cleanup();
|
|
251
|
-
});
|
|
252
|
-
// Handle uncaught errors
|
|
253
|
-
process.on('uncaughtException', error => {
|
|
254
|
-
logger?.('Uncaught exception:', error);
|
|
255
|
-
void cleanup(1);
|
|
256
|
-
});
|
|
257
|
-
process.on('unhandledRejection', error => {
|
|
258
|
-
logger?.('Unhandled rejection:', error);
|
|
259
|
-
void cleanup(1);
|
|
272
|
+
// The shutdown wiring — three signals and the two uncaught-error handlers, each
|
|
273
|
+
// naming itself in the reason the CLI reports — lives in
|
|
274
|
+
// `opera/daemonShutdown.ts`. Every path that reaches it records why it left, so
|
|
275
|
+
// "left no reason behind" means only what it says: a SIGKILL or the OOM killer.
|
|
276
|
+
installShutdownHandlers({
|
|
277
|
+
onSignal: reason => cleanup(0, reason),
|
|
278
|
+
onException: reason => cleanup(1, reason),
|
|
260
279
|
});
|
|
261
280
|
// Start the server
|
|
262
281
|
const started = startSocketServer().catch(error => {
|
|
263
282
|
logger?.('Failed to start daemon server:', error);
|
|
264
|
-
void
|
|
283
|
+
void reportStartupFailure(error, {
|
|
284
|
+
socketBound: bound,
|
|
285
|
+
sessionId,
|
|
286
|
+
teardown: reason => cleanup(1, reason),
|
|
287
|
+
});
|
|
265
288
|
});
|
|
266
289
|
//# sourceMappingURL=daemon.js.map
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
export const PACKAGE_NAME = 'opera-devtools-mcp';
|
|
16
16
|
/** MCP server binary / daemon app name, also used as `process.title`. */
|
|
17
17
|
export const MCP_BIN_NAME = PACKAGE_NAME;
|
|
18
|
-
/** CLI binary name, e.g. `opera-
|
|
19
|
-
export const CLI_BIN_NAME = 'opera-
|
|
18
|
+
/** CLI binary name, e.g. `opera-browser-cli start`. The fork's sole CLI. */
|
|
19
|
+
export const CLI_BIN_NAME = 'opera-browser-cli';
|
|
20
|
+
/** Directory under `$HOME` holding the per-user config file (matches the CLI binary name). */
|
|
21
|
+
export const STATE_DIR_NAME = '.opera-browser-cli';
|
|
20
22
|
/** Human-readable product name used in log lines. */
|
|
21
23
|
export const PRODUCT_NAME = 'Opera DevTools MCP Server';
|
|
22
24
|
/** Public repository, referenced from help text and disclaimers. */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Who is using the shared browser at this moment.
|
|
9
|
+
*
|
|
10
|
+
* One daemon serves every terminal through one MCP server and one browser
|
|
11
|
+
* (`../browser.ts` holds a module-level singleton), so invocations that know
|
|
12
|
+
* nothing about each other overlap freely: an Opera AI action streams for
|
|
13
|
+
* minutes while another terminal's `take_snapshot` arrives, and a second
|
|
14
|
+
* `opera_do` joins the first. That is fine for reading the page and fatal for
|
|
15
|
+
* *replacing the browser*, which closes every page in it — including the pages
|
|
16
|
+
* of whatever is still running.
|
|
17
|
+
*
|
|
18
|
+
* `opera/browserFlags.ts` is the one thing that ever replaces the browser, and
|
|
19
|
+
* it asks here first. A count per tool name rather than a boolean because the
|
|
20
|
+
* refusal names the tools holding the browser, and because two invocations of
|
|
21
|
+
* the same tool are two claims rather than one.
|
|
22
|
+
*
|
|
23
|
+
* `ToolHandler` pairs `noteToolStarted` with `noteToolFinished` around every
|
|
24
|
+
* invocation, the second from the same `finally` that releases the tool mutex —
|
|
25
|
+
* so an invocation that fails is not left counted, and neither is one that
|
|
26
|
+
* `beforeInvoke` itself refused (`opera/toolHandlerHooks.ts`).
|
|
27
|
+
*/
|
|
28
|
+
const inFlight = new Map();
|
|
29
|
+
/** Claim the browser for the invocation of `toolName` that is starting. */
|
|
30
|
+
export function noteToolStarted(toolName) {
|
|
31
|
+
inFlight.set(toolName, (inFlight.get(toolName) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
/** Release one claim on the browser; unknown names are ignored. */
|
|
34
|
+
export function noteToolFinished(toolName) {
|
|
35
|
+
const count = inFlight.get(toolName);
|
|
36
|
+
if (count === undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (count > 1) {
|
|
40
|
+
inFlight.set(toolName, count - 1);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
inFlight.delete(toolName);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The tools using the browser besides the invocation of `self` asking, split by
|
|
47
|
+
* name so a refusal can name them, sorted so its wording is stable.
|
|
48
|
+
*/
|
|
49
|
+
export function otherBrowserUsers(self) {
|
|
50
|
+
const names = new Set();
|
|
51
|
+
for (const [name, count] of inFlight) {
|
|
52
|
+
if (name === self ? count > 1 : count > 0) {
|
|
53
|
+
names.add(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...names].sort();
|
|
57
|
+
}
|
|
58
|
+
/** Test seam: forget every in-flight invocation. */
|
|
59
|
+
export function resetBrowserActivity() {
|
|
60
|
+
inFlight.clear();
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=browserActivity.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Browser process-tree teardown, owned by Opera.
|
|
9
|
+
*
|
|
10
|
+
* Chrome calls `setsid()` while it initializes, so the browser is a process
|
|
11
|
+
* group leader and its helpers (GPU, renderer, utility) join *its* group rather
|
|
12
|
+
* than sitting below it in the process tree. That is why a helper survives
|
|
13
|
+
* SIGKILL to the main process: it is a sibling, not a child, so killing the
|
|
14
|
+
* parent orphans it instead of ending it.
|
|
15
|
+
*
|
|
16
|
+
* Killing the group is therefore the only teardown that reaches the helpers. A
|
|
17
|
+
* descendant walk from the browser pid cannot: by the time `disconnected`
|
|
18
|
+
* fires the pid is gone, the helpers are re-parented to init, and the walk
|
|
19
|
+
* returns nothing.
|
|
20
|
+
*
|
|
21
|
+
* On Linux the group id persists as long as any member is alive, even after the
|
|
22
|
+
* leader exits, so `kill(-pgid)` still names the whole group at that point.
|
|
23
|
+
*
|
|
24
|
+
* `watchBrowserForOrphans` is the seam `src/browser.ts` calls: it owns the pid
|
|
25
|
+
* capture and the `disconnected` listener, so the upstream file gets one line
|
|
26
|
+
* after `launch()` rather than a handler body.
|
|
27
|
+
*/
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
/**
|
|
30
|
+
* SIGKILL every process in the browser's process group.
|
|
31
|
+
*
|
|
32
|
+
* Synchronous by design: `process.kill` is a syscall, and this runs from the
|
|
33
|
+
* `disconnected` handler, where the SIGKILL has to reach the group before the
|
|
34
|
+
* event loop yields to whatever teardown is already in flight.
|
|
35
|
+
*
|
|
36
|
+
* Takes the group id rather than the `Browser`: `browser.process()` is not
|
|
37
|
+
* reliable once `disconnected` has fired — the main process is gone, and a pid
|
|
38
|
+
* read at that point could already name an unrelated process that the kernel
|
|
39
|
+
* reused. The caller captures the pid at launch and holds it; the group outlives
|
|
40
|
+
* its leader, so that value still addresses every helper.
|
|
41
|
+
*
|
|
42
|
+
* No-op on Windows, which has no POSIX process groups: there the browser's
|
|
43
|
+
* helpers are its children and Puppeteer's own close path reaps them.
|
|
44
|
+
*/
|
|
45
|
+
export function killBrowserProcessGroup(pid) {
|
|
46
|
+
if (process.platform === 'win32' || !pid) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
process.kill(-pid, 'SIGKILL');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ESRCH — the group is already gone, which is what a graceful `close()`
|
|
54
|
+
// leaves behind, so the signal is a no-op either way. EPERM — the browser
|
|
55
|
+
// is not a group leader, which `setsid()` makes impossible for a browser
|
|
56
|
+
// Puppeteer launched.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether the teardown above must stay its hand — set while *we* close the
|
|
61
|
+
* browser.
|
|
62
|
+
*
|
|
63
|
+
* `disconnected` is not an abnormal-exit signal: Puppeteer's own `close()` ends
|
|
64
|
+
* with `disconnect()`, so a deliberate shutdown fires the same event a crash
|
|
65
|
+
* does. Killing the process group at that moment SIGKILLs a browser that is
|
|
66
|
+
* still flushing its profile — IndexedDB and LevelDB writes are done by its
|
|
67
|
+
* child storage utility, not by the browser process — and a store that loses
|
|
68
|
+
* those writes keeps records pointing at files that never landed. Blink reports
|
|
69
|
+
* the read of such a record as `NotReadableError: Data lost due to missing
|
|
70
|
+
* file. Affected record should be considered irrecoverable` (`indexeddb/`
|
|
71
|
+
* `idb_request_queue_item.cc`), and Opera AI's chat path then fails on every
|
|
72
|
+
* later run in that profile.
|
|
73
|
+
*
|
|
74
|
+
* So the group kill is armed for a browser that goes away without us closing
|
|
75
|
+
* it, and disarmed for the close we asked for.
|
|
76
|
+
*
|
|
77
|
+
* The flag lives in the watching handler's closure, not in module state: a
|
|
78
|
+
* handler for a browser we already closed can fire *after* the next launch has
|
|
79
|
+
* installed its own, and module state would then be read through the new cycle
|
|
80
|
+
* — an arm resets it to false, so the late handler for the deliberately closed
|
|
81
|
+
* browser sees "not ours" and SIGKILLs a group whose helpers may still be
|
|
82
|
+
* flushing that profile. One flag per armed handler means each browser's
|
|
83
|
+
* teardown answers only for its own close.
|
|
84
|
+
*/
|
|
85
|
+
let disarmActiveWatcher;
|
|
86
|
+
/**
|
|
87
|
+
* Mark the teardown that follows as deliberate, so the `disconnected` it emits
|
|
88
|
+
* is not answered with a group kill. Called by both browser-closing paths in
|
|
89
|
+
* `src/browser.ts`, which are the only places that close a browser we launched.
|
|
90
|
+
*/
|
|
91
|
+
export function disarmBrowserOrphanCleanup() {
|
|
92
|
+
disarmActiveWatcher?.();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Arm the browser's teardown: when it disconnects without closing, kill the
|
|
96
|
+
* process group it left behind.
|
|
97
|
+
*
|
|
98
|
+
* The pid is read here, while the browser is alive, because by the time
|
|
99
|
+
* `disconnected` fires the main process is gone and a pid read then could
|
|
100
|
+
* already name a process the kernel reused. `.once` because Puppeteer emits
|
|
101
|
+
* `disconnected` on an abnormal exit and again on `close()`, and one group kill
|
|
102
|
+
* is the whole job.
|
|
103
|
+
*
|
|
104
|
+
* Called from `ensureBrowserLaunched` right after `launch()`, which is the one
|
|
105
|
+
* place in `src/browser.ts` that owns a launched browser. Each launch installs
|
|
106
|
+
* a fresh watcher with its own disarm flag, so a browser launched after a
|
|
107
|
+
* deliberate close is protected again — and the previous launch's watcher,
|
|
108
|
+
* which may fire afterwards, is still answered by its own flag.
|
|
109
|
+
*/
|
|
110
|
+
export function watchBrowserForOrphans(browser) {
|
|
111
|
+
const pgid = browser.process()?.pid;
|
|
112
|
+
let disarmed = false;
|
|
113
|
+
disarmActiveWatcher = () => {
|
|
114
|
+
disarmed = true;
|
|
115
|
+
};
|
|
116
|
+
browser.once('disconnected', () => {
|
|
117
|
+
if (disarmed) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
killBrowserProcessGroup(pgid);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=browserCleanup.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
function attachTarget(target) {
|
|
8
|
+
return target.browserURL ?? target.wsEndpoint ?? target.userDataDir;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The opening of {@link profileInUse}, exported because a second caller has to
|
|
12
|
+
* recognise the failure without matching the prose again: the CLI settles a
|
|
13
|
+
* conflict it finds this way (see `cliCommands.ts`), and the two spellings of
|
|
14
|
+
* "the profile is in use" must not drift apart.
|
|
15
|
+
*/
|
|
16
|
+
export const PROFILE_IN_USE_PREFIX = 'A browser is already running with the profile';
|
|
17
|
+
/** A launch failed because a browser already holds the profile. */
|
|
18
|
+
export function profileInUse(userDataDir) {
|
|
19
|
+
return (`${PROFILE_IN_USE_PREFIX} ${userDataDir}, so a second one cannot be launched on it. ` +
|
|
20
|
+
`Either drive that browser — start it with --remote-debugging-port=<port> (9222 is the usual one) and run with --browser-url=http://127.0.0.1:<port> ` +
|
|
21
|
+
`(or --autoConnect on Chrome 144+) — or launch on a separate profile with --isolated.`);
|
|
22
|
+
}
|
|
23
|
+
/** Whether a failure message is one {@link profileInUse} produced. */
|
|
24
|
+
export function isProfileInUseMessage(message) {
|
|
25
|
+
return message.includes(PROFILE_IN_USE_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The opening of {@link attachFailed} and {@link noDevToolsEndpoint}, which
|
|
29
|
+
* both say the daemon was told to drive a browser that is not there.
|
|
30
|
+
*/
|
|
31
|
+
export const ATTACH_FAILED_PREFIX = 'Could not attach to the browser';
|
|
32
|
+
/**
|
|
33
|
+
* The browser failure a tool reply describes, or undefined when it describes
|
|
34
|
+
* none.
|
|
35
|
+
*
|
|
36
|
+
* Both of these mean the daemon has no browser to drive — one because a launch
|
|
37
|
+
* was refused, the other because the browser it attached to is gone — and both
|
|
38
|
+
* leave it that way until something re-reads the environment, because a daemon
|
|
39
|
+
* fixes its browser at startup. `cliCommands.ts` settles them for that reason.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyBrowserFailure(message) {
|
|
42
|
+
if (isProfileInUseMessage(message)) {
|
|
43
|
+
return 'profile-in-use';
|
|
44
|
+
}
|
|
45
|
+
if (message.includes(ATTACH_FAILED_PREFIX)) {
|
|
46
|
+
return 'unreachable';
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* An attach through a profile found no DevTools endpoint in it, i.e. the browser
|
|
52
|
+
* is running but was not started with remote debugging.
|
|
53
|
+
*/
|
|
54
|
+
export function noDevToolsEndpoint(userDataDir) {
|
|
55
|
+
return (`Could not attach to the browser running with the profile ${userDataDir}: no DevTools endpoint was found there. ` +
|
|
56
|
+
`Check that it is running and that remote debugging is enabled (chrome://inspect/#remote-debugging).`);
|
|
57
|
+
}
|
|
58
|
+
/** An attach failed outright: the browser is gone, or not debuggable. */
|
|
59
|
+
export function attachFailed(target, autoConnect) {
|
|
60
|
+
const at = attachTarget(target);
|
|
61
|
+
const hint = autoConnect ? ' (chrome://inspect/#remote-debugging)' : '';
|
|
62
|
+
return (`Could not attach to the browser${at ? ` at ${at}` : ''}. ` +
|
|
63
|
+
`Check that it is running with remote debugging enabled${hint}. ` +
|
|
64
|
+
`An attached browser is not managed by this daemon, so it is not restarted for you — start it again and re-attach.`);
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=browserErrors.js.map
|