@browserbridge/bbx 1.6.4 → 1.7.1

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.
@@ -1,13 +1,22 @@
1
1
  // @ts-check
2
2
 
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
 
6
7
  import {
7
8
  APP_NAME,
9
+ getBridgeDir,
10
+ getDaemonLogPath,
11
+ getDaemonPidPath,
8
12
  getManifestInstallDir,
13
+ getSocketPath,
9
14
  SUPPORTED_BROWSERS,
10
15
  } from '../../native-host/src/config.js';
16
+ import {
17
+ readDaemonStartHistory,
18
+ summarizeDaemonRestarts,
19
+ } from '../../native-host/src/daemon-process.js';
11
20
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
12
21
  import { methodNeedsTab } from './cli-helpers.js';
13
22
  import { BridgeClient } from './client.js';
@@ -18,6 +27,7 @@ import { BridgeClient } from './client.js';
18
27
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
19
28
  /** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
20
29
  /** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
30
+ /** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
21
31
  /** @typedef {import('./types.js').DoctorReport} DoctorReport */
22
32
  /** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
23
33
 
@@ -156,6 +166,195 @@ export async function checkBrowserManifests() {
156
166
  );
157
167
  }
158
168
 
169
+ /**
170
+ * @param {string} line
171
+ * @param {number} startIndex
172
+ * @returns {{ value: string, nextIndex: number } | null}
173
+ */
174
+ function readShellSingleQuotedToken(line, startIndex) {
175
+ let index = startIndex;
176
+ while (line[index] === ' ') {
177
+ index += 1;
178
+ }
179
+ if (line[index] !== "'") {
180
+ return null;
181
+ }
182
+ index += 1;
183
+
184
+ let value = '';
185
+ while (index < line.length) {
186
+ const endIndex = line.indexOf("'", index);
187
+ if (endIndex === -1) {
188
+ return null;
189
+ }
190
+ value += line.slice(index, endIndex);
191
+ index = endIndex + 1;
192
+ if (line.startsWith("\\''", index)) {
193
+ value += "'";
194
+ index += 3;
195
+ continue;
196
+ }
197
+ return { value, nextIndex: index };
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * @param {string} launcher
205
+ * @returns {{ nodePath: string, hostPath: string } | null}
206
+ */
207
+ function parseNativeHostLauncherTargets(launcher) {
208
+ const windowsMatch = /"([^"\r\n]+)"\s+"([^"\r\n]+)"\s+%\*/u.exec(launcher);
209
+ if (windowsMatch?.[1] && windowsMatch[2]) {
210
+ return {
211
+ nodePath: windowsMatch[1],
212
+ hostPath: windowsMatch[2],
213
+ };
214
+ }
215
+
216
+ const execLine = launcher
217
+ .split(/\r?\n/u)
218
+ .map((line) => line.trim())
219
+ .find((line) => line.startsWith('exec '));
220
+ if (!execLine) {
221
+ return null;
222
+ }
223
+ const nodeToken = readShellSingleQuotedToken(execLine, 'exec '.length);
224
+ if (!nodeToken) {
225
+ return null;
226
+ }
227
+ const hostToken = readShellSingleQuotedToken(execLine, nodeToken.nextIndex);
228
+ if (!hostToken) {
229
+ return null;
230
+ }
231
+ return {
232
+ nodePath: nodeToken.value,
233
+ hostPath: hostToken.value,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Validate installed native messaging manifests and the launcher they point to.
239
+ * This catches stale sudo/global Node installs where the manifest exists but
240
+ * Chrome launches a missing or non-executable script/Node binary.
241
+ *
242
+ * @param {BrowserManifestStatus[]} browserManifests
243
+ * @returns {Promise<NativeHostManifestIssue[]>}
244
+ */
245
+ export async function checkNativeHostManifestHealth(browserManifests) {
246
+ /** @type {NativeHostManifestIssue[]} */
247
+ const issues = [];
248
+ const executableAccessMode = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
249
+ for (const entry of browserManifests) {
250
+ if (!entry.installed) {
251
+ continue;
252
+ }
253
+
254
+ /** @type {Record<string, unknown>} */
255
+ let manifest;
256
+ try {
257
+ const rawManifest = await fs.promises.readFile(entry.manifestPath, 'utf8');
258
+ const parsed = JSON.parse(rawManifest);
259
+ manifest = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
260
+ } catch (error) {
261
+ issues.push({
262
+ browser: entry.browser,
263
+ manifestPath: entry.manifestPath,
264
+ message: `Native host manifest is not readable JSON: ${error instanceof Error ? error.message : String(error)}`,
265
+ });
266
+ continue;
267
+ }
268
+
269
+ const launcherPath = typeof manifest.path === 'string' ? manifest.path : '';
270
+ if (!launcherPath) {
271
+ issues.push({
272
+ browser: entry.browser,
273
+ manifestPath: entry.manifestPath,
274
+ message: 'Native host manifest does not specify a launcher path.',
275
+ });
276
+ continue;
277
+ }
278
+
279
+ let launcherRaw = '';
280
+ try {
281
+ await fs.promises.access(launcherPath, executableAccessMode);
282
+ launcherRaw = await fs.promises.readFile(launcherPath, 'utf8');
283
+ } catch (error) {
284
+ issues.push({
285
+ browser: entry.browser,
286
+ manifestPath: entry.manifestPath,
287
+ message: `Native host launcher is not executable or readable at ${launcherPath}: ${error instanceof Error ? error.message : String(error)}`,
288
+ });
289
+ continue;
290
+ }
291
+
292
+ const launcherTargets = parseNativeHostLauncherTargets(launcherRaw);
293
+ if (!launcherTargets) {
294
+ issues.push({
295
+ browser: entry.browser,
296
+ manifestPath: entry.manifestPath,
297
+ message: `Native host launcher has an unrecognized format at ${launcherPath}. Run \`bbx install --browser ${entry.browser}\` to regenerate it.`,
298
+ });
299
+ continue;
300
+ }
301
+
302
+ try {
303
+ await fs.promises.access(launcherTargets.nodePath, executableAccessMode);
304
+ } catch (error) {
305
+ issues.push({
306
+ browser: entry.browser,
307
+ manifestPath: entry.manifestPath,
308
+ message: `Node executable referenced by the native host launcher is not usable at ${launcherTargets.nodePath}: ${error instanceof Error ? error.message : String(error)}`,
309
+ });
310
+ }
311
+
312
+ try {
313
+ await fs.promises.access(launcherTargets.hostPath, fs.constants.F_OK);
314
+ } catch (error) {
315
+ issues.push({
316
+ browser: entry.browser,
317
+ manifestPath: entry.manifestPath,
318
+ message: `Native host script referenced by the launcher is missing at ${launcherTargets.hostPath}: ${error instanceof Error ? error.message : String(error)}`,
319
+ });
320
+ }
321
+ }
322
+ return issues;
323
+ }
324
+
325
+ /**
326
+ * Check the bridge home dir and the daemon files inside it for writability.
327
+ * Root-owned files (typically left behind by a sudo install or a sudo `bbx`
328
+ * run) make the daemon crash-loop, so doctor must call them out explicitly.
329
+ *
330
+ * @returns {Promise<string[]>} paths that exist but are not writable
331
+ */
332
+ export async function checkUnwritableBridgePaths() {
333
+ // The root-owned-files scenario comes from sudo installs, which do not exist
334
+ // on Windows — and fs.access(W_OK) there only reflects the read-only file
335
+ // attribute, so it would produce false positives.
336
+ if (os.platform() === 'win32') {
337
+ return [];
338
+ }
339
+
340
+ const candidates = [getBridgeDir(), getDaemonPidPath(), getDaemonLogPath()];
341
+ candidates.push(getSocketPath());
342
+
343
+ /** @type {string[]} */
344
+ const unwritable = [];
345
+ for (const candidate of candidates) {
346
+ try {
347
+ await fs.promises.access(candidate, fs.constants.W_OK);
348
+ } catch (error) {
349
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
350
+ if (code !== 'ENOENT') {
351
+ unwritable.push(candidate);
352
+ }
353
+ }
354
+ }
355
+ return unwritable;
356
+ }
357
+
159
358
  /**
160
359
  * @param {DoctorReportOptions} [options={}]
161
360
  * @returns {Promise<DoctorReport>}
@@ -166,6 +365,15 @@ export async function getDoctorReport(options = {}) {
166
365
  const defaultExtensionId = options.defaultExtensionIdInfo || resolveDefaultExtensionId();
167
366
 
168
367
  const browserManifests = await (options.checkBrowserManifests || checkBrowserManifests)();
368
+ const nativeHostManifestIssues = await (
369
+ options.checkNativeHostManifestHealth || checkNativeHostManifestHealth
370
+ )(browserManifests);
371
+ const daemonRestarts = summarizeDaemonRestarts(
372
+ await (options.readDaemonStartHistory || readDaemonStartHistory)()
373
+ );
374
+ const unwritableBridgePaths = await (
375
+ options.checkUnwritableBridgePaths || checkUnwritableBridgePaths
376
+ )();
169
377
  const manifestInstalled = Boolean(manifest) || browserManifests.some((b) => b.installed);
170
378
  const chromiumSandboxedManifestInstalled = browserManifests.some(
171
379
  (entry) =>
@@ -188,6 +396,10 @@ export async function getDoctorReport(options = {}) {
188
396
  routeTabId: null,
189
397
  routeReady: false,
190
398
  routeReason: 'access_disabled',
399
+ daemonRestarts,
400
+ daemonLogPath: getDaemonLogPath(),
401
+ unwritableBridgePaths,
402
+ nativeHostManifestIssues,
191
403
  issues: [],
192
404
  nextSteps: [],
193
405
  browserManifests,
@@ -223,6 +435,24 @@ export async function getDoctorReport(options = {}) {
223
435
  report.extensionConnected = false;
224
436
  }
225
437
 
438
+ if (unwritableBridgePaths.length > 0) {
439
+ report.issues.push('bridge_files_not_writable');
440
+ report.nextSteps.push(
441
+ `These Browser Bridge files are not writable by the current user (usually caused by installing or running bbx with sudo): ${unwritableBridgePaths.join(', ')}. Fix ownership with: sudo chown -R "$USER" "${getBridgeDir()}"`
442
+ );
443
+ }
444
+ if (nativeHostManifestIssues.length > 0) {
445
+ report.issues.push('native_host_manifest_invalid');
446
+ report.nextSteps.push(
447
+ `The native host manifest or launcher is broken: ${nativeHostManifestIssues.map((issue) => issue.message).join(' ')} Reinstall the native host with \`bbx install --browser <browser>\` or \`bbx install --all\`.`
448
+ );
449
+ }
450
+ if (daemonRestarts.restartLoop) {
451
+ report.issues.push('daemon_restart_loop');
452
+ report.nextSteps.push(
453
+ `The bridge daemon started ${daemonRestarts.startsInWindow} times in the last ${Math.round(daemonRestarts.windowMs / 1000)}s, which means it keeps crashing shortly after startup. Check the daemon log for the underlying error: ${report.daemonLogPath}`
454
+ );
455
+ }
226
456
  if (!report.manifestInstalled) {
227
457
  report.issues.push('native_host_manifest_missing');
228
458
  report.nextSteps.push(
@@ -98,6 +98,18 @@ export interface BrowserManifestStatus {
98
98
  installed: boolean;
99
99
  }
100
100
 
101
+ export interface DaemonRestartSummary {
102
+ startsInWindow: number;
103
+ windowMs: number;
104
+ restartLoop: boolean;
105
+ }
106
+
107
+ export interface NativeHostManifestIssue {
108
+ browser: string;
109
+ manifestPath: string;
110
+ message: string;
111
+ }
112
+
101
113
  export interface DoctorReport {
102
114
  manifestInstalled: boolean;
103
115
  manifestPath: string;
@@ -111,6 +123,10 @@ export interface DoctorReport {
111
123
  routeTabId: number | null;
112
124
  routeReady: boolean;
113
125
  routeReason: string;
126
+ daemonRestarts: DaemonRestartSummary;
127
+ daemonLogPath: string;
128
+ unwritableBridgePaths: string[];
129
+ nativeHostManifestIssues: NativeHostManifestIssue[];
114
130
  issues: string[];
115
131
  nextSteps: string[];
116
132
  browserManifests: BrowserManifestStatus[];
@@ -124,6 +140,11 @@ export interface DoctorReportOptions {
124
140
  bridgeClientRunner?: <T>(
125
141
  callback: (client: { request: BridgeClientRequest }) => Promise<T>
126
142
  ) => Promise<T>;
143
+ readDaemonStartHistory?: () => Promise<number[]>;
144
+ checkUnwritableBridgePaths?: () => Promise<string[]>;
145
+ checkNativeHostManifestHealth?: (
146
+ browserManifests: BrowserManifestStatus[]
147
+ ) => Promise<NativeHostManifestIssue[]>;
127
148
  }
128
149
 
129
150
  export type BridgeClientRequest = (options: {
@@ -168,7 +168,7 @@ export async function handleSetupTool(args) {
168
168
  }
169
169
 
170
170
  /**
171
- * @param {{ limit?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
171
+ * @param {{ limit?: number, budgetPreset?: 'quick' | 'normal' | 'deep', destinationId?: string }} args
172
172
  * @returns {Promise<ToolResult>}
173
173
  */
174
174
  export async function handleLogTool(args) {
@@ -184,6 +184,7 @@ export async function handleLogTool(args) {
184
184
  },
185
185
  {
186
186
  tokenBudget: getToolTokenBudget(normalizedArgs),
187
+ destinationId: typeof args.destinationId === 'string' ? args.destinationId : null,
187
188
  }
188
189
  );
189
190
  }
@@ -149,6 +149,7 @@ export function createBridgeMcpServer() {
149
149
  .enum(['quick', 'normal', 'deep'])
150
150
  .optional()
151
151
  .describe(BUDGET_PRESET_DESCRIPTION),
152
+ destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
152
153
  },
153
154
  },
154
155
  handleLogTool
@@ -704,7 +705,9 @@ export function createBridgeMcpServer() {
704
705
  title: 'Request Browser Bridge Access',
705
706
  description:
706
707
  'Request window access for Browser Bridge. Surfaces an Enable prompt in the extension popup or side panel. Use once per window; if access is already pending, ask the user to enable that window instead of requesting again.',
707
- inputSchema: {},
708
+ inputSchema: {
709
+ destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
710
+ },
708
711
  },
709
712
  handleAccessTool
710
713
  );
@@ -7,7 +7,11 @@ import {
7
7
  getBridgeTransport,
8
8
  } from '../src/config.js';
9
9
  import { DaemonLogger } from '../src/daemon-logger.js';
10
- import { clearDaemonPidFile, writeDaemonPidFile } from '../src/daemon-process.js';
10
+ import {
11
+ clearDaemonPidFile,
12
+ recordDaemonStart,
13
+ writeDaemonPidFile,
14
+ } from '../src/daemon-process.js';
11
15
 
12
16
  applyWindowsTcpTransportDefaults();
13
17
  const transport = getBridgeTransport();
@@ -25,7 +29,6 @@ function isExistingDaemonError(error) {
25
29
 
26
30
  try {
27
31
  await daemon.start();
28
- await writeDaemonPidFile(process.pid);
29
32
  } catch (error) {
30
33
  if (isExistingDaemonError(error)) {
31
34
  process.stdout.write(`${error.message}\n`);
@@ -35,6 +38,18 @@ try {
35
38
  process.exit(1);
36
39
  }
37
40
 
41
+ await recordDaemonStart();
42
+
43
+ try {
44
+ await writeDaemonPidFile(process.pid);
45
+ } catch (error) {
46
+ // A stale root-owned pid file (e.g. after a sudo install) must not take down
47
+ // a daemon that is already listening; `bbx` falls back to lsof for the pid.
48
+ process.stderr.write(
49
+ `Could not write daemon pid file: ${error instanceof Error ? error.message : String(error)}\n`
50
+ );
51
+ }
52
+
38
53
  process.stdout.write(`Browser Bridge daemon listening on ${formatBridgeTransport(transport)}\n`);
39
54
 
40
55
  let shuttingDown = false;
@@ -16,6 +16,18 @@ import { installNativeManifest } from '../src/install-manifest.js';
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const repoRoot = path.resolve(__dirname, '../../..');
18
18
 
19
+ /**
20
+ * npm exec may run package lifecycle hooks while resolving the local bin. Keep
21
+ * transient `npm exec -- bbx ...` invocations from rewriting host setup or
22
+ * replacing a working daemon.
23
+ *
24
+ * @param {NodeJS.ProcessEnv} [env=process.env]
25
+ * @returns {boolean}
26
+ */
27
+ function shouldSkipPostinstall(env = process.env) {
28
+ return env.npm_command === 'exec';
29
+ }
30
+
19
31
  /**
20
32
  * @param {{
21
33
  * installNativeManifestFn?: typeof installNativeManifest,
@@ -23,6 +35,7 @@ const repoRoot = path.resolve(__dirname, '../../..');
23
35
  * stdout?: Pick<NodeJS.WriteStream, 'write'>,
24
36
  * stderr?: Pick<NodeJS.WriteStream, 'write'>,
25
37
  * exit?: (code?: number) => void,
38
+ * env?: NodeJS.ProcessEnv,
26
39
  * }} [deps]
27
40
  * @returns {Promise<void>}
28
41
  */
@@ -32,7 +45,12 @@ export async function runPostinstall({
32
45
  stdout = process.stdout,
33
46
  stderr = process.stderr,
34
47
  exit = (code) => process.exit(code),
48
+ env = process.env,
35
49
  } = {}) {
50
+ if (shouldSkipPostinstall(env)) {
51
+ return;
52
+ }
53
+
36
54
  try {
37
55
  await installNativeManifestFn({ repoRoot, preserveCustomExtensionId: true });
38
56
  stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
@@ -292,6 +292,29 @@ export function getDaemonPidPath(env = process.env) {
292
292
  return path.join(getBridgeDir(env), 'daemon.pid');
293
293
  }
294
294
 
295
+ /**
296
+ * Persistent daemon log. The daemon is normally spawned detached with its
297
+ * stdio redirected here so startup failures stay diagnosable after the
298
+ * process is gone.
299
+ *
300
+ * @param {NodeJS.ProcessEnv} [env=process.env]
301
+ * @returns {string}
302
+ */
303
+ export function getDaemonLogPath(env = process.env) {
304
+ return path.join(getBridgeDir(env), 'daemon.log');
305
+ }
306
+
307
+ /**
308
+ * Rolling history of daemon start timestamps, used to detect a daemon that
309
+ * keeps restarting within a short window (crash loop).
310
+ *
311
+ * @param {NodeJS.ProcessEnv} [env=process.env]
312
+ * @returns {string}
313
+ */
314
+ export function getDaemonStartHistoryPath(env = process.env) {
315
+ return path.join(getBridgeDir(env), 'daemon-starts.json');
316
+ }
317
+
295
318
  /**
296
319
  * @returns {string}
297
320
  */
@@ -12,7 +12,9 @@ import {
12
12
  createSocketBridgeTransport,
13
13
  formatBridgeTransport,
14
14
  getBridgeTransport,
15
+ getDaemonLogPath,
15
16
  getDaemonPidPath,
17
+ getDaemonStartHistoryPath,
16
18
  } from './config.js';
17
19
 
18
20
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
@@ -22,6 +24,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
24
  const daemonEntryPath = path.resolve(__dirname, '../bin/bridge-daemon.js');
23
25
  const DEFAULT_DAEMON_RESTART_TIMEOUT_MS = 5_000;
24
26
  const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
27
+ const DAEMON_LOG_MAX_BYTES = 1024 * 1024;
28
+ const DAEMON_START_HISTORY_MAX_ENTRIES = 20;
29
+
30
+ /**
31
+ * A daemon that starts this many times within the window is considered to be
32
+ * crash-looping rather than restarting normally.
33
+ */
34
+ export const DAEMON_RESTART_LOOP_THRESHOLD = 3;
35
+ export const DAEMON_RESTART_LOOP_WINDOW_MS = 60_000;
25
36
 
26
37
  /**
27
38
  * @typedef {{
@@ -56,18 +67,114 @@ const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
56
67
  * }} RestartBridgeDaemonOptions
57
68
  */
58
69
 
70
+ /**
71
+ * Open the persistent daemon log for appending, rotating it once when it grows
72
+ * past the size cap. Returns null when the log cannot be opened (for example a
73
+ * root-owned bridge dir after a sudo install) so callers can fall back to
74
+ * discarding output instead of failing the spawn.
75
+ *
76
+ * @param {string} [logPath=getDaemonLogPath()]
77
+ * @returns {number | null}
78
+ */
79
+ export function openDaemonLogFd(logPath = getDaemonLogPath()) {
80
+ try {
81
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
82
+ try {
83
+ if (fs.statSync(logPath).size > DAEMON_LOG_MAX_BYTES) {
84
+ fs.rmSync(`${logPath}.1`, { force: true });
85
+ fs.renameSync(logPath, `${logPath}.1`);
86
+ }
87
+ } catch {
88
+ // Rotation is best-effort: on Windows the rename fails while another
89
+ // daemon holds the log open. Keep appending to the current file.
90
+ }
91
+ return fs.openSync(logPath, 'a');
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
59
97
  /**
60
98
  * @returns {import('node:child_process').ChildProcess}
61
99
  */
62
100
  export function spawnBridgeDaemonProcess() {
101
+ const logFd = openDaemonLogFd();
63
102
  const child = spawn(process.execPath, [daemonEntryPath], {
64
103
  detached: true,
65
- stdio: 'ignore',
104
+ stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
66
105
  });
67
106
  child.unref();
107
+ if (logFd !== null) {
108
+ fs.closeSync(logFd);
109
+ }
68
110
  return child;
69
111
  }
70
112
 
113
+ /**
114
+ * Read the rolling daemon start history (epoch ms timestamps, oldest first).
115
+ * Missing or malformed files read as an empty history.
116
+ *
117
+ * @param {string} [historyPath=getDaemonStartHistoryPath()]
118
+ * @returns {Promise<number[]>}
119
+ */
120
+ export async function readDaemonStartHistory(historyPath = getDaemonStartHistoryPath()) {
121
+ try {
122
+ const parsed = JSON.parse(await fs.promises.readFile(historyPath, 'utf8'));
123
+ if (!Array.isArray(parsed)) {
124
+ return [];
125
+ }
126
+ return parsed.filter(
127
+ (value) => typeof value === 'number' && Number.isFinite(value) && value > 0
128
+ );
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Append a daemon start timestamp to the rolling history. Best-effort: any
136
+ * filesystem failure is swallowed so an unwritable bridge dir never prevents
137
+ * the daemon from starting.
138
+ *
139
+ * @param {{ at?: number, historyPath?: string }} [options={}]
140
+ * @returns {Promise<number[]>} the updated history (empty when persisting failed)
141
+ */
142
+ export async function recordDaemonStart(options = {}) {
143
+ const { at = Date.now(), historyPath = getDaemonStartHistoryPath() } = options;
144
+ try {
145
+ const history = [...(await readDaemonStartHistory(historyPath)), at].slice(
146
+ -DAEMON_START_HISTORY_MAX_ENTRIES
147
+ );
148
+ await fs.promises.mkdir(path.dirname(historyPath), { recursive: true });
149
+ await fs.promises.writeFile(historyPath, `${JSON.stringify(history)}\n`, 'utf8');
150
+ return history;
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Summarize the daemon start history over the recent window so callers can
158
+ * tell a crash-looping daemon apart from a normal restart.
159
+ *
160
+ * @param {number[]} history
161
+ * @param {{ now?: number, windowMs?: number, threshold?: number }} [options={}]
162
+ * @returns {{ startsInWindow: number, windowMs: number, restartLoop: boolean }}
163
+ */
164
+ export function summarizeDaemonRestarts(history, options = {}) {
165
+ const {
166
+ now = Date.now(),
167
+ windowMs = DAEMON_RESTART_LOOP_WINDOW_MS,
168
+ threshold = DAEMON_RESTART_LOOP_THRESHOLD,
169
+ } = options;
170
+ const startsInWindow = history.filter((at) => at <= now && now - at <= windowMs).length;
171
+ return {
172
+ startsInWindow,
173
+ windowMs,
174
+ restartLoop: startsInWindow >= threshold,
175
+ };
176
+ }
177
+
71
178
  /**
72
179
  * @param {number} [pid=process.pid]
73
180
  * @param {string} [pidPath=getDaemonPidPath()]
@@ -141,7 +248,8 @@ export async function stopBridgeDaemon(options = {}) {
141
248
  const resolvedSocketPath =
142
249
  resolvedTransport.type === 'socket' ? resolvedTransport.socketPath : '';
143
250
 
144
- let previousPid = await readPidFn(pidPath);
251
+ const pidFromFile = await readPidFn(pidPath);
252
+ let previousPid = pidFromFile;
145
253
  let previouslyRunning = previousPid !== null;
146
254
 
147
255
  if (previousPid === null && (await safePingDaemon(resolvedTransport, pingDaemonFn))) {
@@ -158,7 +266,7 @@ export async function stopBridgeDaemon(options = {}) {
158
266
  }
159
267
  }
160
268
 
161
- const stopped = await waitForDaemonReachability({
269
+ let stopped = await waitForDaemonReachability({
162
270
  transport: resolvedTransport,
163
271
  reachable: false,
164
272
  timeoutMs,
@@ -166,12 +274,38 @@ export async function stopBridgeDaemon(options = {}) {
166
274
  pingDaemonFn,
167
275
  sleepFn,
168
276
  });
277
+ if (!stopped) {
278
+ // The pid file can be stale (e.g. a root-owned leftover from a sudo
279
+ // install that the daemon could not overwrite) while a daemon with a
280
+ // different pid owns the socket. Target the socket owner before giving up.
281
+ const socketOwnerPid = await findPidByTransportFn(resolvedTransport);
282
+ if (socketOwnerPid !== null && socketOwnerPid !== previousPid) {
283
+ try {
284
+ killFn(socketOwnerPid, 'SIGTERM');
285
+ } catch (error) {
286
+ if (!isMissingProcessError(error)) {
287
+ throw error;
288
+ }
289
+ }
290
+ stopped = await waitForDaemonReachability({
291
+ transport: resolvedTransport,
292
+ reachable: false,
293
+ timeoutMs,
294
+ pollIntervalMs,
295
+ pingDaemonFn,
296
+ sleepFn,
297
+ });
298
+ if (stopped) {
299
+ previousPid = socketOwnerPid;
300
+ }
301
+ }
302
+ }
169
303
  if (!stopped) {
170
304
  throw new Error(`Timed out waiting for Browser Bridge daemon (pid ${previousPid}) to stop.`);
171
305
  }
172
306
  }
173
307
 
174
- await clearDaemonPidFile({ pid: previousPid, pidPath, rmFn });
308
+ await clearDaemonPidFile({ pid: pidFromFile ?? previousPid, pidPath, rmFn });
175
309
 
176
310
  const removedStaleSocket = await removeStaleSocket(resolvedTransport, rmFn, pingDaemonFn);
177
311
  return {