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
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
* Daemon lifecycle supervision, owned by Opera.
|
|
9
|
+
*
|
|
10
|
+
* The upstream daemon is spawned `detached: true` and `unref()`ed, so the CLI
|
|
11
|
+
* is not its parent and nothing recovers the process tree when it goes away
|
|
12
|
+
* badly. Two holes follow from that, and this module closes both without
|
|
13
|
+
* touching the daemon's own supervision of its children:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Liveness is pid-file-only.** `isDaemonRunning` reads the pid file, so a
|
|
16
|
+
* daemon whose pid file was removed (or whose socket was unlinked by a
|
|
17
|
+
* racing starter) is invisible: the next `start` forks a second daemon and
|
|
18
|
+
* the first keeps running as an orphan holding the session's real socket.
|
|
19
|
+
* `ensureCleanStart` probes the socket itself — the one signal that survives
|
|
20
|
+
* a missing pid file — and stops any daemon it finds there.
|
|
21
|
+
*
|
|
22
|
+
* 2. **A dead daemon leaves its process group behind.** SIGKILL (what the OOM
|
|
23
|
+
* killer sends) runs no handler, so the MCP server and the browser stay up
|
|
24
|
+
* re-parented to init. `ensureCleanStart` kills the whole process group of a
|
|
25
|
+
* dead pid, which is what reaps them: the daemon is a session leader
|
|
26
|
+
* (`detached: true` calls `setsid()`), so every child it spawned shares its
|
|
27
|
+
* group id — including group members whose leader has already exited.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here deletes the pid or socket files. File creation belongs to the
|
|
30
|
+
* daemon's own `O_EXCL` claim (`daemon.ts`); a client that unlinked them would
|
|
31
|
+
* reopen the race this module exists to close.
|
|
32
|
+
*/
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import net from 'node:net';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import process from 'node:process';
|
|
37
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
38
|
+
import { getDaemonPid, getRuntimeHome, getSocketPath, IS_WINDOWS, } from '../daemon/utils.js';
|
|
39
|
+
import { PipeTransport } from '../third_party/index.js';
|
|
40
|
+
import { logger, puppeteerLogger } from '../utils/logger.js';
|
|
41
|
+
/** How long a socket probe waits to connect before concluding nothing is there. */
|
|
42
|
+
const SOCKET_CONNECT_TIMEOUT_MS = 1_000;
|
|
43
|
+
/**
|
|
44
|
+
* How long it then waits for the reply, once something accepted the connection.
|
|
45
|
+
* Longer than the connect wait on purpose: a daemon that connected is alive, and
|
|
46
|
+
* a busy one — a long tool call, a GC pause, a stalled disk — answers late rather
|
|
47
|
+
* than never. Declaring it dead on a 1s reply window forks a second daemon whose
|
|
48
|
+
* `O_EXCL` claim then fails, which reaches the user as a spurious startup error.
|
|
49
|
+
*/
|
|
50
|
+
const SOCKET_REPLY_TIMEOUT_MS = 5_000;
|
|
51
|
+
/** How long to wait for a daemon to exit after asking it to stop. */
|
|
52
|
+
const STOP_WAIT_TIMEOUT_MS = 5_000;
|
|
53
|
+
const STOP_WAIT_POLL_MS = 50;
|
|
54
|
+
/** Where a dying daemon explains itself to the next CLI invocation. */
|
|
55
|
+
const EXIT_REASON_FILE = 'daemon-error';
|
|
56
|
+
/**
|
|
57
|
+
* `process.kill(pid, 0)` with the semantics the callers want: `EPERM` means the
|
|
58
|
+
* pid exists but belongs to another user, which still counts as alive.
|
|
59
|
+
*/
|
|
60
|
+
function isProcessAlive(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return error.code === 'EPERM';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get rid of a daemon that is known to be dead or unresponsive. POSIX: SIGKILL
|
|
71
|
+
* its whole process group, which is what reaps the MCP server, the browser and
|
|
72
|
+
* Chrome's helpers along with it (the daemon is a session leader, and the group
|
|
73
|
+
* outlives its leader as long as one member is alive).
|
|
74
|
+
*
|
|
75
|
+
* `ESRCH` (the group is already gone) is the expected outcome once the daemon
|
|
76
|
+
* exited cleanly, so it is not logged as a failure.
|
|
77
|
+
*/
|
|
78
|
+
function killStaleDaemon(pid) {
|
|
79
|
+
if (IS_WINDOWS) {
|
|
80
|
+
// No process groups to signal, so the daemon itself is the only handle
|
|
81
|
+
// available here. What it spawned is not reachable from this call.
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 'SIGKILL');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// already gone
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
process.kill(-pid, 'SIGKILL');
|
|
92
|
+
logger?.(`Killed the process group of daemon ${pid}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const code = error.code;
|
|
96
|
+
if (code !== 'ESRCH') {
|
|
97
|
+
logger?.(`Could not kill the process group of daemon ${pid}:`, error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
if (!isProcessAlive(pid)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
await sleep(STOP_WAIT_POLL_MS);
|
|
108
|
+
}
|
|
109
|
+
return !isProcessAlive(pid);
|
|
110
|
+
}
|
|
111
|
+
/** The pid out of a raw `status` reply, or null if the reply is not one. */
|
|
112
|
+
function readPidFromStatus(rawReply) {
|
|
113
|
+
try {
|
|
114
|
+
const response = JSON.parse(rawReply);
|
|
115
|
+
if (!response.success || typeof response.result !== 'string') {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const status = JSON.parse(response.result);
|
|
119
|
+
return typeof status.pid === 'number' ? status.pid : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Open the session's socket and send one framed command, resolving with the
|
|
127
|
+
* daemon's raw reply. `null` means the caller should act as if the session is
|
|
128
|
+
* empty: the socket file is missing, nobody accepts the connection, or the
|
|
129
|
+
* connection broke before a reply arrived.
|
|
130
|
+
*
|
|
131
|
+
* A connection that is accepted and then goes quiet is logged separately. It
|
|
132
|
+
* means a daemon exists and is too busy (or too wedged) to answer, which is a
|
|
133
|
+
* different thing to debug than no daemon at all — but the same thing to do
|
|
134
|
+
* about, because nothing here can stop a daemon that will not talk.
|
|
135
|
+
*
|
|
136
|
+
* This bypasses `sendCommand`, whose pid-file guard is the very check that
|
|
137
|
+
* cannot see the daemon we are looking for.
|
|
138
|
+
*/
|
|
139
|
+
async function probeSocket(sessionId, command) {
|
|
140
|
+
const socketPath = getSocketPath(sessionId);
|
|
141
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
142
|
+
const socket = net.createConnection({ path: socketPath });
|
|
143
|
+
let settled = false;
|
|
144
|
+
let timer;
|
|
145
|
+
const settle = (reply) => {
|
|
146
|
+
if (settled) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
settled = true;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
socket.destroy();
|
|
152
|
+
resolve(reply);
|
|
153
|
+
};
|
|
154
|
+
const armTimer = (timeoutMs, waitingFor) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
timer = setTimeout(() => {
|
|
157
|
+
logger?.(`Daemon probe on ${socketPath} timed out after ${timeoutMs}ms ${waitingFor}`);
|
|
158
|
+
settle(null);
|
|
159
|
+
}, timeoutMs);
|
|
160
|
+
};
|
|
161
|
+
armTimer(SOCKET_CONNECT_TIMEOUT_MS, 'waiting for a connection');
|
|
162
|
+
socket.on('error', (error) => {
|
|
163
|
+
// ECONNREFUSED, ENOENT or a mid-reply reset: nothing to talk to.
|
|
164
|
+
logger?.(`Daemon probe on ${socketPath} failed: ${error.code ?? error.message}`);
|
|
165
|
+
settle(null);
|
|
166
|
+
});
|
|
167
|
+
socket.on('connect', () => {
|
|
168
|
+
armTimer(SOCKET_REPLY_TIMEOUT_MS, `waiting for a reply to ${command.method}`);
|
|
169
|
+
const transport = new PipeTransport(socket, socket, puppeteerLogger);
|
|
170
|
+
transport.onmessage = (message) => settle(message);
|
|
171
|
+
transport.send(JSON.stringify(command));
|
|
172
|
+
});
|
|
173
|
+
return await promise;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Make the session safe to start a daemon in.
|
|
177
|
+
*
|
|
178
|
+
* Returns `true` when a live daemon already serves the session — the caller
|
|
179
|
+
* should simply wait for it to become ready — and `false` when the caller
|
|
180
|
+
* should fork one. Any daemon that the pid file does not mention, and any
|
|
181
|
+
* process group left behind by a daemon that died without cleaning up, is gone
|
|
182
|
+
* by the time this resolves.
|
|
183
|
+
*/
|
|
184
|
+
export async function ensureCleanStart(sessionId) {
|
|
185
|
+
const pid = getDaemonPid(sessionId);
|
|
186
|
+
if (pid !== null) {
|
|
187
|
+
if (isProcessAlive(pid)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// The pid file outlived its daemon. Everything the daemon spawned — MCP
|
|
191
|
+
// server, browser, Chrome helpers — shares its process group, and the group
|
|
192
|
+
// still exists as long as one member does, so this single kill reaps the
|
|
193
|
+
// tree that a SIGKILLed daemon left behind.
|
|
194
|
+
logger?.(`Pid file names the dead daemon ${pid}; reaping its process group`);
|
|
195
|
+
killStaleDaemon(pid);
|
|
196
|
+
}
|
|
197
|
+
const reply = await probeSocket(sessionId, { method: 'status' });
|
|
198
|
+
const hiddenPid = reply === null ? null : readPidFromStatus(reply);
|
|
199
|
+
if (hiddenPid !== null) {
|
|
200
|
+
logger?.(`Found daemon ${hiddenPid}, which the pid file does not mention; stopping it`);
|
|
201
|
+
await probeSocket(sessionId, { method: 'stop' });
|
|
202
|
+
if (!(await waitForProcessExit(hiddenPid, STOP_WAIT_TIMEOUT_MS))) {
|
|
203
|
+
// It took the request and did not go. Leaving it is precisely the
|
|
204
|
+
// invisible orphan this probe exists to remove - the caller is about to
|
|
205
|
+
// fork a daemon that will unlink its socket - so stop asking.
|
|
206
|
+
logger?.(`Daemon ${hiddenPid} ignored stop; killing its process group`);
|
|
207
|
+
killStaleDaemon(hiddenPid);
|
|
208
|
+
await waitForProcessExit(hiddenPid, STOP_WAIT_TIMEOUT_MS);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Nothing owns the session now. Drop a stale exit reason so a later failure
|
|
212
|
+
// is not reported with a previous daemon's explanation. This is the only
|
|
213
|
+
// place the reason is cleared: `readExitReason` deliberately leaves it in
|
|
214
|
+
// place, because the CLI's readiness loop reads it on every retry and reports
|
|
215
|
+
// its *last* error — a read that consumed the file would hand the user a
|
|
216
|
+
// "Daemon is not running." with no explanation.
|
|
217
|
+
try {
|
|
218
|
+
fs.rmSync(exitReasonPath(sessionId), { force: true });
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// best-effort
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
/** The file a daemon leaves behind to explain its own exit. */
|
|
226
|
+
function exitReasonPath(sessionId) {
|
|
227
|
+
return path.join(getRuntimeHome(sessionId), EXIT_REASON_FILE);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Record why a daemon is going away, for the next CLI invocation to surface.
|
|
231
|
+
* Best-effort: the daemon is on its way out, and a reason file that cannot be
|
|
232
|
+
* written must not be the thing that keeps it alive.
|
|
233
|
+
*/
|
|
234
|
+
export function writeExitReason(sessionId, message) {
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(exitReasonPath(sessionId), message);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// best-effort
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The daemon's exit reason, if it left one. Left in place on purpose: a readiness
|
|
244
|
+
* loop reads this on every retry and reports its last error, so the file is only
|
|
245
|
+
* cleared where the session is known clean — `ensureCleanStart`, before a new
|
|
246
|
+
* daemon is forked. Any caller that reads the reason without that having run
|
|
247
|
+
* will keep seeing it; that is the invariant, not a leak.
|
|
248
|
+
*/
|
|
249
|
+
export function readExitReason(sessionId) {
|
|
250
|
+
try {
|
|
251
|
+
return fs.readFileSync(exitReasonPath(sessionId), 'utf-8').trim() || null;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=daemonLifecycle.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import { getRuntimeHome } from '../daemon/utils.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { readExitReason } from './daemonLifecycle.js';
|
|
13
|
+
/**
|
|
14
|
+
* The daemon's own stdout/stderr capture point, inside the session's runtime
|
|
15
|
+
* home.
|
|
16
|
+
*/
|
|
17
|
+
export function getDaemonLogPath(sessionId) {
|
|
18
|
+
return path.join(getRuntimeHome(sessionId), 'daemon.log');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The log of the daemon before the current one. The log is rotated rather than
|
|
22
|
+
* truncated — the CLI points at it when a daemon was killed — so this is where a
|
|
23
|
+
* killed daemon's output lives once the session has started a new one.
|
|
24
|
+
*/
|
|
25
|
+
export function getPreviousDaemonLogPath(sessionId) {
|
|
26
|
+
return `${getDaemonLogPath(sessionId)}.prev`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Open the daemon's log for the spawn, creating the runtime directory the daemon
|
|
30
|
+
* would otherwise create itself.
|
|
31
|
+
*
|
|
32
|
+
* The directory is chmod-ed after creation rather than relying on `mkdirSync`'s
|
|
33
|
+
* mode, which is masked by the umask and does nothing at all to a directory that
|
|
34
|
+
* already exists. The daemon refuses to run on a group/world-writable runtime
|
|
35
|
+
* directory, so the mode has to hold whether the CLI or the daemon created it.
|
|
36
|
+
*
|
|
37
|
+
* Nothing here is allowed to stop the daemon from starting: a log is a
|
|
38
|
+
* diagnostic, and a runtime dir this process cannot write (another user's, a
|
|
39
|
+
* read-only mount) degrades to `'ignore'` stdio with a warning instead of
|
|
40
|
+
* killing the spawn. `O_NOFOLLOW` so a planted symlink is never written
|
|
41
|
+
* through — the pid file gets the same guarantee.
|
|
42
|
+
*/
|
|
43
|
+
export function openDaemonLog(sessionId) {
|
|
44
|
+
try {
|
|
45
|
+
const logPath = getDaemonLogPath(sessionId);
|
|
46
|
+
const dir = path.dirname(logPath);
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
if (process.platform !== 'win32') {
|
|
49
|
+
fs.chmodSync(dir, 0o700);
|
|
50
|
+
}
|
|
51
|
+
// Rotate instead of truncating: this file is where a killed daemon's output
|
|
52
|
+
// lives, and the CLI points at it when a daemon left no reason behind.
|
|
53
|
+
// Keeping one previous generation bounds the growth without throwing away
|
|
54
|
+
// the only evidence of the previous failure.
|
|
55
|
+
try {
|
|
56
|
+
fs.renameSync(logPath, getPreviousDaemonLogPath(sessionId));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// No previous log — the common case.
|
|
60
|
+
}
|
|
61
|
+
return fs.openSync(logPath, fs.constants.O_WRONLY |
|
|
62
|
+
fs.constants.O_CREAT |
|
|
63
|
+
fs.constants.O_APPEND |
|
|
64
|
+
fs.constants.O_NOFOLLOW, 0o600);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
logger?.('Could not open the daemon log; the daemon will run without one:', error);
|
|
68
|
+
return 'ignore';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Why the daemon is gone, for a command that was already in flight.
|
|
73
|
+
*
|
|
74
|
+
* A daemon that ran its own teardown leaves a reason; one that was killed
|
|
75
|
+
* cannot, and that difference is the whole point of the message. `readExitReason`
|
|
76
|
+
* leaves the file in place, so this can be called on every attempt.
|
|
77
|
+
*/
|
|
78
|
+
export function daemonExitMessage(sessionId) {
|
|
79
|
+
const reason = readExitReason(sessionId);
|
|
80
|
+
if (reason) {
|
|
81
|
+
return `Daemon exited while running the command: ${reason}`;
|
|
82
|
+
}
|
|
83
|
+
return ('Daemon exited while running the command and left no reason behind, ' +
|
|
84
|
+
'which means it was killed rather than shut down (SIGKILL, the OOM killer). ' +
|
|
85
|
+
`Its output is at ${getDaemonLogPath(sessionId)}` +
|
|
86
|
+
`, or at ${getPreviousDaemonLogPath(sessionId)} if a daemon has started since.`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The rejection a spawn failure deserves, for `Promise.race` against the
|
|
90
|
+
* pid-file wait.
|
|
91
|
+
*
|
|
92
|
+
* A spawn that fails outright — a missing script, an unusable execPath — emits
|
|
93
|
+
* `'error'` and never creates the pid file, which would otherwise surface as an
|
|
94
|
+
* unexplained ready-timeout. The log names the reason; this names the log.
|
|
95
|
+
*/
|
|
96
|
+
export function nameSpawnFailure(child, sessionId) {
|
|
97
|
+
return new Promise((_, reject) => {
|
|
98
|
+
child.on('error', error => {
|
|
99
|
+
reject(new Error(`Failed to start the daemon: ${error.message}. Its log is at ${getDaemonLogPath(sessionId)}.`, { cause: error }));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=daemonLog.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* The daemon's pid file, behind an `O_EXCL` claim.
|
|
9
|
+
*
|
|
10
|
+
* Upstream opens the file with `O_TRUNC`, which makes the pid file a last-writer
|
|
11
|
+
* -wins slot rather than a lock: two clients that both observe "not running"
|
|
12
|
+
* both spawn a daemon, the second one truncates the first one's file and then
|
|
13
|
+
* unlinks its socket, and the session is left with one visible daemon and one
|
|
14
|
+
* deaf one. `O_EXCL` makes exactly one of them the owner; the loser gets
|
|
15
|
+
* `EEXIST` and exits before it can touch anything.
|
|
16
|
+
*
|
|
17
|
+
* A file left by a daemon that died is still reclaimable, but only after
|
|
18
|
+
* re-reading it to confirm it names that same dead pid: a third daemon may have
|
|
19
|
+
* claimed it in between. The re-read *narrows* the window to the gap between
|
|
20
|
+
* that comparison and the unlink — microseconds, and the loser of any race here
|
|
21
|
+
* retries `O_EXCL` and exits, so it does not close it outright.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import { constants, openSync } from 'node:fs';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { getDaemonPid, getPidFilePath } from '../daemon/utils.js';
|
|
27
|
+
const PID_FILE_FLAGS = constants.O_WRONLY |
|
|
28
|
+
constants.O_CREAT |
|
|
29
|
+
constants.O_EXCL |
|
|
30
|
+
constants.O_NOFOLLOW;
|
|
31
|
+
/**
|
|
32
|
+
* Remove the pid file of a daemon that is no longer alive. False when the file
|
|
33
|
+
* belongs to a live daemon, or to one that is mid-write and has not written its
|
|
34
|
+
* pid yet — unlinking either would hide a daemon that is starting up.
|
|
35
|
+
*/
|
|
36
|
+
function reclaimStalePidFile(sessionId) {
|
|
37
|
+
const pidFilePath = getPidFilePath(sessionId);
|
|
38
|
+
const existingPid = getDaemonPid(sessionId);
|
|
39
|
+
if (existingPid === null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
process.kill(existingPid, 0);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Dead pid: the file is ours to reclaim.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (fs.readFileSync(pidFilePath, 'utf-8').trim() !== existingPid.toString()) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
fs.unlinkSync(pidFilePath);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Open the session's pid file and return the descriptor to write this process's
|
|
62
|
+
* pid into. Throws — the daemon is expected to report and exit — if another
|
|
63
|
+
* daemon owns it.
|
|
64
|
+
*
|
|
65
|
+
* `O_NOFOLLOW` means a symlink is never followed, and the returned descriptor
|
|
66
|
+
* is created `0o600`: readable and writable by its owner alone.
|
|
67
|
+
*/
|
|
68
|
+
export function claimPidFile(sessionId) {
|
|
69
|
+
const pidFilePath = getPidFilePath(sessionId);
|
|
70
|
+
try {
|
|
71
|
+
return openSync(pidFilePath, PID_FILE_FLAGS, 0o600);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err.code !== 'EEXIST' ||
|
|
75
|
+
!reclaimStalePidFile(sessionId)) {
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// One retry: the window between the unlink and this open is nanoseconds, so a
|
|
80
|
+
// second `EEXIST` means another daemon genuinely won the race.
|
|
81
|
+
return openSync(pidFilePath, PID_FILE_FLAGS, 0o600);
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=daemonPidFile.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
|
+
/**
|
|
8
|
+
* How the daemon goes away, and what it says about it.
|
|
9
|
+
*
|
|
10
|
+
* Two problems, both about the daemon's last words. Every shutdown path has to
|
|
11
|
+
* record *why* it is leaving — the CLI reads that reason when a command was in
|
|
12
|
+
* flight, and a shutdown that records nothing is reported there as a kill — and
|
|
13
|
+
* the handlers that do it have to exist before anything can go wrong, which is
|
|
14
|
+
* why the daemon installs them next to its other startup work rather than deep
|
|
15
|
+
* in its teardown.
|
|
16
|
+
*
|
|
17
|
+
* A shutdown on request is not a kill: `SIGTERM` from a supervisor, `SIGINT`
|
|
18
|
+
* from a terminal and `SIGHUP` from a closed shell each name themselves, so
|
|
19
|
+
* "left no reason behind" is left to mean what it says — a `SIGKILL` or the OOM
|
|
20
|
+
* killer, neither of which can run a handler at all.
|
|
21
|
+
*/
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
import { writeExitReason } from './daemonLifecycle.js';
|
|
24
|
+
/** Every signal the daemon treats as "stop", and the words it reports them with. */
|
|
25
|
+
const SHUTDOWN_SIGNALS = [
|
|
26
|
+
['SIGTERM', 'terminated by signal: SIGTERM'],
|
|
27
|
+
['SIGINT', 'interrupted by signal: SIGINT'],
|
|
28
|
+
['SIGHUP', 'hung up on signal: SIGHUP'],
|
|
29
|
+
];
|
|
30
|
+
function describeError(kind, error) {
|
|
31
|
+
return `${kind}: ${error instanceof Error ? error.message : String(error)}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Install the daemon's signal and uncaught-error handlers.
|
|
35
|
+
*
|
|
36
|
+
* The stack goes to stderr rather than `logger` because stderr *is* the daemon
|
|
37
|
+
* log (stdio is redirected to it at spawn), and a `logger` stream would be
|
|
38
|
+
* dropped by the `process.exit` the teardown ends in.
|
|
39
|
+
*/
|
|
40
|
+
export function installShutdownHandlers(handlers) {
|
|
41
|
+
for (const [signal, reason] of SHUTDOWN_SIGNALS) {
|
|
42
|
+
process.on(signal, () => {
|
|
43
|
+
void handlers.onSignal(reason);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
process.on('uncaughtException', error => {
|
|
47
|
+
console.error('[MCP Daemon] Uncaught exception:', error);
|
|
48
|
+
void handlers.onException(describeError('uncaught exception', error));
|
|
49
|
+
});
|
|
50
|
+
process.on('unhandledRejection', error => {
|
|
51
|
+
console.error('[MCP Daemon] Unhandled rejection:', error);
|
|
52
|
+
void handlers.onException(describeError('unhandled rejection', error));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Record why a daemon is going away, for the next CLI invocation to surface.
|
|
57
|
+
* Best-effort: the daemon is on its way out, and a reason file that cannot be
|
|
58
|
+
* written must not be the thing that keeps it alive. A shutdown with no reason
|
|
59
|
+
* to give writes nothing.
|
|
60
|
+
*/
|
|
61
|
+
export function recordShutdownReason(sessionId, reason) {
|
|
62
|
+
if (reason) {
|
|
63
|
+
writeExitReason(sessionId, reason);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=daemonShutdown.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
* The daemon's socket edges: the frame it is answering (`dispatchSocketMessage`,
|
|
9
|
+
* which routes a streaming request through `answerSocketMessage`), and the
|
|
10
|
+
* failure it reports when it cannot start listening at all.
|
|
11
|
+
*
|
|
12
|
+
* Both exist because the daemon's own error handling is a trapdoor. A frame that
|
|
13
|
+
* is not JSON rejects the transport callback, and the daemon's
|
|
14
|
+
* `unhandledRejection` handler runs `cleanup(1)`: one malformed byte from
|
|
15
|
+
* anything that can open the socket takes the whole session down. And a startup
|
|
16
|
+
* failure that runs `cleanup()` unlinks the socket file — which, when the
|
|
17
|
+
* failure is a lost bind race, belongs to the daemon that won. The winner is
|
|
18
|
+
* left alive and deaf, which is the orphan this whole area exists to prevent.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import process from 'node:process';
|
|
22
|
+
import { writeExitReason } from './daemonLifecycle.js';
|
|
23
|
+
import { withLogSink } from './daemonStreaming.js';
|
|
24
|
+
/**
|
|
25
|
+
* Parse one framed socket message and hand it to `handle`, answering with an
|
|
26
|
+
* error reply when it is not JSON — or when `handle` itself throws. The reply is
|
|
27
|
+
* a reply, not an exception: the caller sends it and closes the connection, and
|
|
28
|
+
* nothing on this path is allowed to reach the daemon's `unhandledRejection`
|
|
29
|
+
* handler, whose answer to any stray rejection is to tear the session down.
|
|
30
|
+
*/
|
|
31
|
+
export async function answerSocketMessage(raw, handle) {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { success: false, error: 'malformed message' };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return await handle(parsed);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
error: error instanceof Error ? error.message : String(error),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Answer one framed socket message, streaming requests included.
|
|
51
|
+
*
|
|
52
|
+
* A streaming request is the `invoke_tool` variant that opted in, and it gets a
|
|
53
|
+
* token of its own: the chunks it produces are written to this connection as
|
|
54
|
+
* they arrive, and the same final response frame follows. Two connections may
|
|
55
|
+
* stream at once, so the token is what keeps their chunks apart — see
|
|
56
|
+
* `opera/daemonStreaming.ts`. Every other message goes straight to `handle`.
|
|
57
|
+
*/
|
|
58
|
+
export async function dispatchSocketMessage(raw, transport, handle) {
|
|
59
|
+
return answerSocketMessage(raw, message => {
|
|
60
|
+
if (message.method === 'invoke_tool' && message.stream === true) {
|
|
61
|
+
const streamToken = randomUUID();
|
|
62
|
+
return withLogSink(streamToken, chunk => {
|
|
63
|
+
transport.send(JSON.stringify({ log: chunk }));
|
|
64
|
+
}, () => handle(message, streamToken));
|
|
65
|
+
}
|
|
66
|
+
return handle(message);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Report why the daemon could not start, and get out of the way.
|
|
71
|
+
*
|
|
72
|
+
* A daemon that lost the bind race must not run the caller's `teardown`: it
|
|
73
|
+
* would unlink the socket file the winner is listening on and leave a live,
|
|
74
|
+
* unreachable daemon behind. It exits bare instead, leaving a reason for the
|
|
75
|
+
* next CLI invocation to surface. A failure after a successful bind is ours to
|
|
76
|
+
* clean up, socket and all — and it records why, like every other shutdown.
|
|
77
|
+
*/
|
|
78
|
+
export async function reportStartupFailure(error, options) {
|
|
79
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
80
|
+
if (options.socketBound) {
|
|
81
|
+
await options.teardown(`startup failed: ${detail}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
writeExitReason(options.sessionId, `socket bind failed: ${detail}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=daemonSocket.js.map
|