@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.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/CHANGELOG.md +165 -0
- package/LICENSE +27 -0
- package/NOTICE +34 -0
- package/README.md +452 -2
- package/README.zh-CN.md +328 -0
- package/bin/browser-use-mcp.mjs +83 -0
- package/docs/architecture.md +210 -0
- package/docs/clients.md +425 -0
- package/docs/config.md +260 -0
- package/lib/cli.js +146 -0
- package/lib/config.js +341 -0
- package/lib/doctor.js +62 -0
- package/lib/engine.js +717 -0
- package/lib/http.js +222 -0
- package/lib/jev.js +105 -0
- package/lib/log.js +32 -0
- package/lib/provision.js +585 -0
- package/lib/python.js +470 -0
- package/lib/render.js +75 -0
- package/lib/server.js +193 -0
- package/lib/session.js +484 -0
- package/lib/sidecar.js +472 -0
- package/lib/tools.js +352 -0
- package/package.json +69 -4
- package/sidecar/bridge.py +982 -0
- package/vendor/jev-ultrafast.json +6 -0
- package/vendor/jev_ultrafast-0.1.1-py3-none-any.whl +0 -0
- package/vendor/requirements.txt +74 -0
package/lib/sidecar.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One sidecar process: line-delimited JSON over stdio, one browser session inside.
|
|
3
|
+
*
|
|
4
|
+
* A sidecar is a *generation*: when its browser cannot be shut down cleanly the process is abandoned
|
|
5
|
+
* rather than reused, and the next browser this session opens gets a new generation with its own
|
|
6
|
+
* daemon name. Reusing a half-dead daemon is how a "close then reopen" turns into a tool call that
|
|
7
|
+
* fails for reasons nobody can see.
|
|
8
|
+
*
|
|
9
|
+
* Adapted from `dsh-browser-use` (`lib/sidecar.js`, MIT): the protocol is the vendored
|
|
10
|
+
* `sidecar/bridge.py`'s, unchanged.
|
|
11
|
+
*
|
|
12
|
+
* @module browser-use-mcp/sidecar
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { readFileSync, unlinkSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
20
|
+
import { createInterface } from 'node:readline'
|
|
21
|
+
|
|
22
|
+
import { ensureEngine, reportText, SIDECAR } from './engine.js'
|
|
23
|
+
import { ENGINE_VARIABLES } from './jev.js'
|
|
24
|
+
|
|
25
|
+
/** The largest delay `setTimeout` accepts; anything above it is clamped to 1 ms by Node. */
|
|
26
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How long a daemon socket path may be.
|
|
30
|
+
*
|
|
31
|
+
* The engine's IPC is an AF_UNIX socket, and `sun_path` is 104 bytes on macOS (108 on Linux), NUL
|
|
32
|
+
* included. A profile name is allowed to be long, and the daemon path is built from it, so the
|
|
33
|
+
* budget is checked before a browser is ever started: the failure would otherwise surface as the
|
|
34
|
+
* engine's opaque `fatal: AF_UNIX path too long` on every call.
|
|
35
|
+
*/
|
|
36
|
+
const SOCKET_PATH_BUDGET = 100
|
|
37
|
+
|
|
38
|
+
/** The error a cancelled request settles with; the browser may still complete what it started. */
|
|
39
|
+
export function cancelled(signal) {
|
|
40
|
+
const error = new Error(
|
|
41
|
+
'the operation was cancelled. The browser may still finish what it already started, '
|
|
42
|
+
+ 'so read the page again with browser_page before acting on it.',
|
|
43
|
+
)
|
|
44
|
+
error.kind = 'cancelled'
|
|
45
|
+
if (signal?.reason !== undefined) error.cause = signal.reason
|
|
46
|
+
return error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The error a call settles with when the engine environment could not be made ready. */
|
|
50
|
+
export class EngineNotReady extends Error {
|
|
51
|
+
constructor(report) {
|
|
52
|
+
super(`the browser engine is not ready.\n\n${reportText(report)}`)
|
|
53
|
+
this.name = 'EngineNotReady'
|
|
54
|
+
this.kind = 'engine'
|
|
55
|
+
this.report = report
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The error a call settles with when the engine install it waited for was cancelled. */
|
|
60
|
+
export function cancelledInstall(signal) {
|
|
61
|
+
const error = cancelled(signal)
|
|
62
|
+
error.message = 'the operation was cancelled while it waited for the browser engine install. '
|
|
63
|
+
+ 'The install goes on: the next browser call waits for it, or uses what it installed.'
|
|
64
|
+
return error
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Settle with `promise`, or with `failure(signal)` as soon as `signal` aborts.
|
|
69
|
+
*
|
|
70
|
+
* Only the wait is cancelled: the work behind `promise` goes on, and whoever asks for it next joins
|
|
71
|
+
* it rather than starting it again.
|
|
72
|
+
*/
|
|
73
|
+
export function abortable(promise, signal, failure = cancelled) {
|
|
74
|
+
if (signal === undefined || signal === null) return promise
|
|
75
|
+
if (signal.aborted) {
|
|
76
|
+
promise.catch(() => {})
|
|
77
|
+
return Promise.reject(failure(signal))
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const abort = () => reject(failure(signal))
|
|
81
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
82
|
+
promise.then(
|
|
83
|
+
value => {
|
|
84
|
+
signal.removeEventListener('abort', abort)
|
|
85
|
+
resolve(value)
|
|
86
|
+
},
|
|
87
|
+
error => {
|
|
88
|
+
signal.removeEventListener('abort', abort)
|
|
89
|
+
reject(error)
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The daemon name one generation owns.
|
|
97
|
+
*
|
|
98
|
+
* Browser Harness names every daemon and keeps a registration per name: a name a previous generation
|
|
99
|
+
* did not release cleanly would be handed back to the next launch. A generation suffix keeps that
|
|
100
|
+
* failure inside the generation that caused it.
|
|
101
|
+
* @param {string} label - the session's filesystem-safe label.
|
|
102
|
+
* @param {number} generation - 1 for the first browser this session opens.
|
|
103
|
+
*/
|
|
104
|
+
export function daemonName(label, generation) {
|
|
105
|
+
const suffix = generation <= 1 ? '' : `-g${generation}`
|
|
106
|
+
const stem = String(label).replace(/[^A-Za-z0-9_-]/gu, '').slice(0, 64 - suffix.length) || 'session'
|
|
107
|
+
return `browser-use-mcp-${stem}${suffix}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The environment a sidecar starts from: this process's, minus the engine's model variables.
|
|
112
|
+
*
|
|
113
|
+
* Jev's endpoints and keys travel with each request that spends them instead of living in the child,
|
|
114
|
+
* and one stale key in the server's own environment must not become every run's key. Browser
|
|
115
|
+
* Harness's shared-runtime switch is set when a runtime directory is inherited: several sessions may
|
|
116
|
+
* share it, and the engine would otherwise collapse their daemon names to one.
|
|
117
|
+
* @param {NodeJS.ProcessEnv} [source] - the environment to start from; `process.env` unless a caller says otherwise.
|
|
118
|
+
*/
|
|
119
|
+
export function inheritedEnvironment(source = process.env) {
|
|
120
|
+
const env = { ...source }
|
|
121
|
+
for (const name of ENGINE_VARIABLES) delete env[name]
|
|
122
|
+
// One server may run several sidecars against one caller-supplied directory; Browser Harness would
|
|
123
|
+
// otherwise collapse every daemon name to the bare `bu` stem and let one session find — and stop —
|
|
124
|
+
// another session's daemon. Say the directory is shared whether it came in as BH_RUNTIME_DIR or as
|
|
125
|
+
// the legacy BH_TMP_DIR (the engine treats the latter as the runtime directory too).
|
|
126
|
+
if ((env.BH_RUNTIME_DIR || env.BH_TMP_DIR) && env.BH_RUNTIME_DIR_SHARED !== '1') {
|
|
127
|
+
env.BH_RUNTIME_DIR_SHARED = '1'
|
|
128
|
+
}
|
|
129
|
+
if (env.BH_TMP_DIR && env.BH_TMP_DIR_SHARED !== '1') env.BH_TMP_DIR_SHARED = '1'
|
|
130
|
+
return env
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `~`/`~/…` expanded, the way the engine's own path handling does it. */
|
|
134
|
+
function expandHome(value) {
|
|
135
|
+
if (value === '~') return homedir()
|
|
136
|
+
if (value.startsWith('~/')) return join(homedir(), value.slice(1))
|
|
137
|
+
return value
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where Browser Harness keeps one daemon's pid and endpoint files.
|
|
142
|
+
*
|
|
143
|
+
* These are the engine's own documented conventions (`browser_harness/_ipc.py`, pinned by
|
|
144
|
+
* `vendor/requirements.txt`): the runtime directory comes from `BH_RUNTIME_DIR`, then `BH_TMP_DIR`,
|
|
145
|
+
* then `<config>/runtime`; the file stem is `bu-<name>` unless a caller-supplied runtime directory is
|
|
146
|
+
* declared isolated by not setting the `_SHARED` switch. This server starts several sidecars, so it
|
|
147
|
+
* always sets the shared switch and the stem always carries the daemon name.
|
|
148
|
+
*/
|
|
149
|
+
function daemonFiles(env, name) {
|
|
150
|
+
const home = env.BH_HOME || env.BROWSER_HARNESS_HOME
|
|
151
|
+
|| (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'browser-harness') : join(homedir(), '.config', 'browser-harness'))
|
|
152
|
+
const caller = env.BH_RUNTIME_DIR || env.BH_TMP_DIR
|
|
153
|
+
const runtime = caller ? resolve(expandHome(caller)) : join(home, 'runtime')
|
|
154
|
+
const isolated = caller !== undefined && env.BH_RUNTIME_DIR_SHARED !== '1'
|
|
155
|
+
const stem = isolated ? 'bu' : `bu-${name}`
|
|
156
|
+
return {
|
|
157
|
+
pidPath: join(runtime, `${stem}.pid`),
|
|
158
|
+
sockPath: join(runtime, `${stem}.sock`),
|
|
159
|
+
portPath: join(runtime, `${stem}.port`),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The daemon identity one session uses: its name and the files that name owns.
|
|
165
|
+
*
|
|
166
|
+
* A label long enough to push the socket path over the platform's `sun_path` limit is replaced by a
|
|
167
|
+
* stable short digest (same label and generation always give the same name, so a restarted sidecar
|
|
168
|
+
* still finds the daemon it left). When even that cannot fit, the runtime directory itself is the
|
|
169
|
+
* problem and the caller gets the variable that fixes it instead of the engine's opaque error.
|
|
170
|
+
*/
|
|
171
|
+
export function daemonIdentity(env, label, generation) {
|
|
172
|
+
let name = daemonName(label, generation)
|
|
173
|
+
let files = daemonFiles(env, name)
|
|
174
|
+
if (files.sockPath.length > SOCKET_PATH_BUDGET) {
|
|
175
|
+
const digest = createHash('sha256').update(`${label}\u0000${generation}`).digest('hex').slice(0, 10)
|
|
176
|
+
name = `browser-use-mcp-${digest}`
|
|
177
|
+
files = daemonFiles(env, name)
|
|
178
|
+
}
|
|
179
|
+
if (files.sockPath.length > SOCKET_PATH_BUDGET) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`the Browser Harness runtime directory is too long for a Unix socket `
|
|
182
|
+
+ `(${files.sockPath.length} > ${SOCKET_PATH_BUDGET} bytes): ${dirname(files.sockPath)}. `
|
|
183
|
+
+ 'Set BH_RUNTIME_DIR to a shorter directory, or move the state root closer to /.',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
return { name, files }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The pid Browser Harness published for one daemon, or undefined when it published none. */
|
|
190
|
+
function readDaemonPid(files) {
|
|
191
|
+
try {
|
|
192
|
+
const pid = Number(readFileSync(files.pidPath, 'utf8').trim())
|
|
193
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Kill one orphaned daemon and drop the endpoint files it can no longer clean up itself.
|
|
201
|
+
*
|
|
202
|
+
* The bridge terminates the browser and then asks Browser Harness's admin layer to stop the daemon;
|
|
203
|
+
* that clean path can spend fifteen seconds waiting for a daemon that is already wedged, and an MCP
|
|
204
|
+
* client does not allow it. Once the browser is gone the daemon has nothing left to do, so a SIGTERM
|
|
205
|
+
* to the pid it published is the same end the slow path would reach — without the wait, and without
|
|
206
|
+
* leaving a process behind when this server exits. Every step is best effort: a daemon that already
|
|
207
|
+
* exited is not an error.
|
|
208
|
+
*/
|
|
209
|
+
function stopDaemon(files, logger) {
|
|
210
|
+
const pid = readDaemonPid(files)
|
|
211
|
+
if (pid !== undefined) {
|
|
212
|
+
try {
|
|
213
|
+
process.kill(pid, 'SIGTERM')
|
|
214
|
+
logger?.debug?.(`sent SIGTERM to orphaned browser-harness daemon ${pid}`)
|
|
215
|
+
if (process.platform !== 'win32') {
|
|
216
|
+
// A daemon that ignores SIGTERM must still be gone; the detached shell survives this process.
|
|
217
|
+
spawn('/bin/sh', ['-c', `sleep 3; kill -9 ${pid} 2>/dev/null || true`], {
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: 'ignore',
|
|
220
|
+
}).unref()
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
logger?.debug?.(`could not signal browser-harness daemon ${pid}: ${error?.message ?? error}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const path of [files.pidPath, files.sockPath, files.portPath]) {
|
|
227
|
+
try {
|
|
228
|
+
unlinkSync(path)
|
|
229
|
+
} catch {
|
|
230
|
+
// Already cleaned by the daemon itself; nothing to do.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One sidecar process and the request/answer bookkeeping around it. */
|
|
236
|
+
export class Sidecar {
|
|
237
|
+
#child = null
|
|
238
|
+
#interpreter = null
|
|
239
|
+
#lines = null
|
|
240
|
+
#pending = new Map()
|
|
241
|
+
#sequence = 0
|
|
242
|
+
#stderr = []
|
|
243
|
+
#problem = ''
|
|
244
|
+
#daemon = null
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {object} options
|
|
248
|
+
* @param {object} options.config - the resolved configuration.
|
|
249
|
+
* @param {string} options.label - a filesystem-safe label for this session.
|
|
250
|
+
* @param {number} [options.generation] - which generation of this session's browser it is.
|
|
251
|
+
* @param {object} [options.logger] - the stderr logger.
|
|
252
|
+
*/
|
|
253
|
+
constructor({ config, label, generation = 1, logger }) {
|
|
254
|
+
this.config = config
|
|
255
|
+
this.label = label
|
|
256
|
+
this.generation = generation
|
|
257
|
+
this.logger = logger
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Why this generation must not be reused, or an empty string while it is healthy. */
|
|
261
|
+
get problem() {
|
|
262
|
+
return this.#problem
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Whether this generation still owns a usable process. */
|
|
266
|
+
get broken() {
|
|
267
|
+
return this.#problem !== ''
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The interpreter the sidecar runs on, once a call has made it ready. */
|
|
271
|
+
get interpreter() {
|
|
272
|
+
return this.#interpreter
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check the engine, install it if this server owns its environment and finds none, then start the
|
|
277
|
+
* sidecar.
|
|
278
|
+
* @param {AbortSignal} [signal] - cancels this call's wait; an install it started goes on.
|
|
279
|
+
*/
|
|
280
|
+
async ready(signal) {
|
|
281
|
+
if (this.broken) throw new Error(`this browser could not be cleaned up (${this.#problem})`)
|
|
282
|
+
const report = await abortable(ensureEngine(this.config), signal, cancelledInstall)
|
|
283
|
+
if (!report.ok) throw new EngineNotReady(report)
|
|
284
|
+
// The probe already found the interpreter that imports the engine: the sidecar runs on that one
|
|
285
|
+
// rather than on a second guess made here.
|
|
286
|
+
this.#interpreter = report.interpreter
|
|
287
|
+
await this.start()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async start() {
|
|
291
|
+
if (this.#child) return
|
|
292
|
+
const { command, prefix = [], cwd } = this.#interpreter ?? {}
|
|
293
|
+
if (!command) throw new Error('the browser engine probe found no interpreter to run the sidecar')
|
|
294
|
+
const environment = inheritedEnvironment()
|
|
295
|
+
// One daemon owns one CDP connection, and Browser Harness names its daemon when it imports.
|
|
296
|
+
const { name, files } = daemonIdentity(environment, this.label, this.generation)
|
|
297
|
+
const env = {
|
|
298
|
+
...environment,
|
|
299
|
+
PYTHONUNBUFFERED: '1',
|
|
300
|
+
BU_NAME: name,
|
|
301
|
+
}
|
|
302
|
+
this.#daemon = { name, files }
|
|
303
|
+
const child = spawn(command, [...prefix, SIDECAR], {
|
|
304
|
+
cwd: cwd || undefined,
|
|
305
|
+
env,
|
|
306
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
307
|
+
})
|
|
308
|
+
this.#child = child
|
|
309
|
+
this.#lines = createInterface({ input: child.stdout })
|
|
310
|
+
this.#lines.on('line', line => this.#answered(line))
|
|
311
|
+
child.stderr.on('data', chunk => {
|
|
312
|
+
const text = String(chunk)
|
|
313
|
+
this.#stderr.push(text)
|
|
314
|
+
if (this.#stderr.length > 40) this.#stderr.shift()
|
|
315
|
+
this.logger?.debug?.(`sidecar stderr: ${text.trim()}`)
|
|
316
|
+
})
|
|
317
|
+
child.on('error', error => {
|
|
318
|
+
// An exit this session did not ask for (stop() clears #child first) means the generation is
|
|
319
|
+
// unusable: reusing its process, daemon name, or page is how a crash turns into a call that
|
|
320
|
+
// fails for reasons nobody can see.
|
|
321
|
+
const unexpected = this.#child === child
|
|
322
|
+
this.#child = null
|
|
323
|
+
if (unexpected) this.#problem = `the browser sidecar could not be started: ${error.message}`
|
|
324
|
+
this.#fail(new Error(`the browser sidecar could not be started: ${error.message}`))
|
|
325
|
+
})
|
|
326
|
+
child.on('exit', (code, signal) => {
|
|
327
|
+
const detail = this.#stderr.join('').trim().slice(-600)
|
|
328
|
+
const unexpected = this.#child === child
|
|
329
|
+
this.#child = null
|
|
330
|
+
const message = `the browser sidecar exited (${code ?? signal})${detail ? `: ${detail}` : ''}`
|
|
331
|
+
if (unexpected) {
|
|
332
|
+
this.#problem = message
|
|
333
|
+
// The sidecar is gone, but the Browser Harness daemon it started may still be running and
|
|
334
|
+
// holding a socket. This generation is never reused, so nothing else would ever stop it: a
|
|
335
|
+
// crash must not leave one Python process behind per session.
|
|
336
|
+
if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
|
|
337
|
+
}
|
|
338
|
+
this.#fail(new Error(message))
|
|
339
|
+
})
|
|
340
|
+
try {
|
|
341
|
+
await this.request('hello', {}, undefined, 120_000)
|
|
342
|
+
} catch (error) {
|
|
343
|
+
// A sidecar that cannot even greet is stopped here, so no half-started process is left behind.
|
|
344
|
+
await this.stop()
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#fail(error) {
|
|
350
|
+
for (const [, entry] of this.#pending) entry.reject(error)
|
|
351
|
+
this.#pending.clear()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#answered(line) {
|
|
355
|
+
let answer
|
|
356
|
+
try {
|
|
357
|
+
answer = JSON.parse(line)
|
|
358
|
+
} catch {
|
|
359
|
+
return // a non-JSON line is the sidecar's own noise, never a protocol answer
|
|
360
|
+
}
|
|
361
|
+
const entry = this.#pending.get(answer.id)
|
|
362
|
+
if (!entry) return
|
|
363
|
+
if (answer.error) {
|
|
364
|
+
const error = Object.assign(new Error(answer.error.message ?? 'refused'), answer.error)
|
|
365
|
+
error.kind = answer.error.kind
|
|
366
|
+
entry.reject(error)
|
|
367
|
+
} else {
|
|
368
|
+
entry.resolve(answer.result)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Send one request and settle it on the answer, the deadline, cancellation, or process death.
|
|
374
|
+
* @param {string} method - engine method name.
|
|
375
|
+
* @param {object} params - engine parameters.
|
|
376
|
+
* @param {AbortSignal} [signal] - the tool call's cancellation signal; aborting settles this promise only.
|
|
377
|
+
* @param {number} [timeoutMs] - deadline for this one request.
|
|
378
|
+
*/
|
|
379
|
+
request(method, params = {}, signal, timeoutMs = this.config.requestTimeoutMs) {
|
|
380
|
+
const child = this.#child
|
|
381
|
+
if (!child) return Promise.reject(new Error('the browser sidecar is not running'))
|
|
382
|
+
if (signal?.aborted) return Promise.reject(cancelled(signal))
|
|
383
|
+
const id = ++this.#sequence
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
let settled = false
|
|
386
|
+
let timer
|
|
387
|
+
const finish = next => {
|
|
388
|
+
if (settled) return
|
|
389
|
+
settled = true
|
|
390
|
+
clearTimeout(timer)
|
|
391
|
+
this.#pending.delete(id)
|
|
392
|
+
signal?.removeEventListener('abort', onAbort)
|
|
393
|
+
next()
|
|
394
|
+
}
|
|
395
|
+
const onAbort = () => finish(() => reject(cancelled(signal)))
|
|
396
|
+
// `Infinity` (or any delay a timer cannot carry) means "no deadline": arming a timer with it
|
|
397
|
+
// would do the opposite, because Node clamps an oversized delay to one millisecond. A goal run
|
|
398
|
+
// is the caller that means this; its real bound is the MCP client's own timeout.
|
|
399
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= MAX_TIMER_MS) {
|
|
400
|
+
timer = setTimeout(() => {
|
|
401
|
+
finish(() => reject(new Error(`${method} did not answer within ${timeoutMs}ms`)))
|
|
402
|
+
}, timeoutMs)
|
|
403
|
+
}
|
|
404
|
+
this.#pending.set(id, {
|
|
405
|
+
resolve: value => finish(() => resolve(value)),
|
|
406
|
+
reject: error => finish(() => reject(error)),
|
|
407
|
+
})
|
|
408
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
409
|
+
try {
|
|
410
|
+
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`)
|
|
411
|
+
} catch (error) {
|
|
412
|
+
finish(() => reject(error))
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Ask the engine to stop the tab, the browser, and the daemon, then make sure the process is gone.
|
|
419
|
+
*
|
|
420
|
+
* The bridge terminates the launched browser *before* it stops the Browser Harness daemon, and that
|
|
421
|
+
* daemon cleanup can take fifteen seconds on this version. An MCP client closes a stdio server in
|
|
422
|
+
* about four, so waiting for the whole shutdown is not an option: the grace below covers the part
|
|
423
|
+
* that matters (the browser is gone within a few hundred milliseconds), and the sidecar is then
|
|
424
|
+
* killed while the daemon finishes on its own. A shutdown the engine reports as failed still marks
|
|
425
|
+
* this generation unusable, because the browser may not have been terminated.
|
|
426
|
+
* @param {object} [options]
|
|
427
|
+
* @param {number} [options.graceMs] - how long to wait for the engine's answer and its exit.
|
|
428
|
+
* @returns {Promise<string>} the shutdown problem, or an empty string when it ended as expected.
|
|
429
|
+
*/
|
|
430
|
+
async stop({ graceMs = 1_000 } = {}) {
|
|
431
|
+
const child = this.#child
|
|
432
|
+
if (!child) {
|
|
433
|
+
// A sidecar that died asynchronously is reaped by its exit handler; this is the belt-and-braces
|
|
434
|
+
// path for a crash the handler could not finish, so a crashed generation does not leak a daemon.
|
|
435
|
+
if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
|
|
436
|
+
return this.#problem
|
|
437
|
+
}
|
|
438
|
+
const began = Date.now()
|
|
439
|
+
try {
|
|
440
|
+
await this.request('shutdown', {}, undefined, graceMs)
|
|
441
|
+
this.logger?.debug?.(`sidecar shut down in ${Date.now() - began}ms`)
|
|
442
|
+
} catch (error) {
|
|
443
|
+
// A client that closes fast leaves the daemon cleanup running; it does not mean the browser is
|
|
444
|
+
// still there — the bridge terminated it before starting that cleanup.
|
|
445
|
+
this.logger?.debug?.(`sidecar shutdown did not answer in ${graceMs}ms: ${error?.message ?? error}`)
|
|
446
|
+
}
|
|
447
|
+
this.#child = null
|
|
448
|
+
this.#fail(new Error('the browser sidecar was closed'))
|
|
449
|
+
let killed = false
|
|
450
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
451
|
+
child.stdin.end()
|
|
452
|
+
const ended = new Promise(resolve => child.once('exit', resolve))
|
|
453
|
+
// The answer already told us whether the browser got its terminate; a sidecar still alive here
|
|
454
|
+
// is finishing the daemon cleanup and can be killed without harming the browser.
|
|
455
|
+
const exited = await Promise.race([
|
|
456
|
+
ended.then(() => true),
|
|
457
|
+
new Promise(resolve => setTimeout(() => resolve(false), 400)),
|
|
458
|
+
])
|
|
459
|
+
if (!exited && child.exitCode === null) {
|
|
460
|
+
killed = true
|
|
461
|
+
child.kill('SIGKILL')
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Whether this stop killed the sidecar or it managed to exit, this generation owns a daemon that
|
|
465
|
+
// must not outlive it; stopDaemon is best effort and idempotent.
|
|
466
|
+
if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
|
|
467
|
+
if (this.#problem === '' && child.exitCode !== 0 && child.signalCode !== null && child.signalCode !== 'SIGKILL') {
|
|
468
|
+
this.#problem = `the sidecar was killed with ${child.signalCode}`
|
|
469
|
+
}
|
|
470
|
+
return this.#problem
|
|
471
|
+
}
|
|
472
|
+
}
|