dsh-builtin-browser 0.4.1 → 0.4.2

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.
Files changed (44) hide show
  1. package/README.en.md +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +302 -26
  44. package/src/tool-browser/index.ts +143 -35
@@ -25,9 +25,34 @@
25
25
  * @module dsh-browser/browser-electron/system-browser
26
26
  */
27
27
 
28
- import { readFileSync, rmSync } from 'node:fs'
28
+ import { readFileSync, rmSync, statSync } from 'node:fs'
29
29
  import { existsSync } from 'node:fs'
30
30
  import { join } from 'node:path'
31
+
32
+ /**
33
+ * How long a killed browser is given to actually exit before the launch proceeds.
34
+ *
35
+ * `kill()` only sends the signal — on Windows it is TerminateProcess, which is asynchronous —
36
+ * and the browser may hold this profile's singleton lock for a while afterwards. Spawning
37
+ * immediately meant the replacement saw a live instance, handed its command line over and
38
+ * exited, and that exit was reported as a bad browser path.
39
+ */
40
+ const EXIT_GRACE_MS = 3_000
41
+
42
+ /**
43
+ * Starts a browser process and returns a handle to it.
44
+ *
45
+ * Injectable so tests can supply something that speaks CDP — the seam a recovery test needs
46
+ * and could not have while the launch was hardcoded to a stub that rejects Chromium's args.
47
+ * @param path - the executable to run.
48
+ * @param args - its arguments.
49
+ * @returns the child process.
50
+ */
51
+ export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>
52
+
53
+ /** The real launcher: spawn a detached-free child with pipes closed. */
54
+ const defaultLauncher: BrowserLauncher = (path, args) =>
55
+ spawn(path, [...args], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
31
56
  import { spawn, type ChildProcess } from 'node:child_process'
32
57
  import { randomUUID } from 'node:crypto'
33
58
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
@@ -83,6 +108,11 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
83
108
  win32: [
84
109
  String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
85
110
  String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
111
+ // Where a per-user install lands, which is what Chrome's own installer chooses when it
112
+ // cannot (or should not) write to Program Files. Missing this meant the plugin reported
113
+ // "no browser found" on machines that plainly had one.
114
+ String.raw`%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`,
115
+ String.raw`%LOCALAPPDATA%\Chromium\Application\chrome.exe`,
86
116
  ],
87
117
  darwin: [
88
118
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -100,6 +130,7 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
100
130
  win32: [
101
131
  String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
102
132
  String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
133
+ String.raw`%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe`,
103
134
  ],
104
135
  darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
105
136
  linux: [
@@ -112,12 +143,29 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
112
143
  win32: [
113
144
  String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
114
145
  String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
146
+ String.raw`%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe`,
115
147
  ],
116
148
  darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
117
149
  linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
118
150
  },
119
151
  }
120
152
 
153
+ /**
154
+ * Resolve the environment references in one install location.
155
+ *
156
+ * The list is static strings, but a per-user install lands under %LOCALAPPDATA%, so the entry
157
+ * has to be resolved against the environment detection was HANDED rather than process.env — a
158
+ * caller that supplies a platform and an environment (which is what makes detection testable)
159
+ * must not end up reading the developer's real machine.
160
+ * @param location - one entry from the install-location table.
161
+ * @param env - the environment to resolve against.
162
+ * @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
163
+ */
164
+ function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
165
+ return location.replace(/%([^%]+)%/g, (whole, name: string) =>
166
+ env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
167
+ }
168
+
121
169
  /**
122
170
  * Find an executable by name on PATH.
123
171
  *
@@ -172,7 +220,8 @@ export function detectBrowser(
172
220
  if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
173
221
  const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform)
174
222
  if (onPath !== undefined) return { kind, path: onPath }
175
- for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
223
+ for (const raw of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
224
+ const candidate = expandLocation(raw, env)
176
225
  if (existsSync(candidate)) return { kind, path: candidate }
177
226
  }
178
227
  }
@@ -189,9 +238,15 @@ export function detectBrowser(
189
238
  * @param platform - the platform whose names and locations apply.
190
239
  * @returns the launcher names (as PATH lookups) and the fixed locations checked.
191
240
  */
192
- export function searchSummary(kind: DetectedBrowser['kind'], platform: NodeJS.Platform = process.platform): string[] {
241
+ export function searchSummary(
242
+ kind: DetectedBrowser['kind'],
243
+ platform: NodeJS.Platform = process.platform,
244
+ env: NodeJS.ProcessEnv = process.env,
245
+ ): string[] {
193
246
  const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`)
194
- const located = INSTALL_LOCATIONS[kind]?.[platform] ?? []
247
+ // Expanded, so the report names the location that was actually tested rather than a template
248
+ // the user cannot paste into a file manager.
249
+ const located = (INSTALL_LOCATIONS[kind]?.[platform] ?? []).map(entry => expandLocation(entry, env))
195
250
  return [...onPath, ...located]
196
251
  }
197
252
 
@@ -310,7 +365,19 @@ class CdpClient {
310
365
 
311
366
  /** A system browser driven over CDP, presented as a browser view host. */
312
367
  export class SystemBrowserViewHost implements ElectronBrowserViewHost {
313
- private readonly views = new Map<string, string>()
368
+ /**
369
+ * viewId -> the session that view rides, and the client that issued it.
370
+ *
371
+ * The client is part of the value on purpose. A session id means nothing to a browser other
372
+ * than the one that handed it out, and this host replaces browsers routinely: the process
373
+ * dies, or its debugging socket closes while the process lives on. Keeping only the id meant
374
+ * the host could start a NEW browser successfully and then send every command to it carrying
375
+ * a session the old one had issued — `Session with given id not found`, on every call, with a
376
+ * browser that answers /json/version perfectly well and nothing the user can do short of
377
+ * restarting DSH. Comparing identity catches it with no round trip; validating each cached
378
+ * session against the browser would put one on every page command.
379
+ */
380
+ private readonly views = new Map<string, { session: string; client: CdpClient }>()
314
381
  private disposed = false
315
382
  /**
316
383
  * The browser is started on FIRST USE, never on construction.
@@ -321,21 +388,47 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
321
388
  * spawned when a view is actually needed.
322
389
  */
323
390
  private client: CdpClient | undefined
391
+ /**
392
+ * Set when a command went unanswered rather than being refused.
393
+ *
394
+ * A timeout says nothing about the connection: the page may simply be slow. It marks the
395
+ * client for a cheap check before the next command trusts it, which keeps a slow page
396
+ * from being mistaken for a dead browser while still catching one that has truly stopped
397
+ * answering.
398
+ */
399
+ private clientSuspect = false
324
400
  private child: ChildProcess | undefined
325
401
  private starting: Promise<CdpClient> | undefined
326
402
 
403
+ /**
404
+ * Browsers that ignored a kill.
405
+ *
406
+ * They are no longer this host's child — a replacement has taken that slot — but they
407
+ * are still running, still holding the profile, and still something release must try to
408
+ * stop. Forgetting them is what allowed a launch this host could not undo.
409
+ */
410
+ private readonly stubborn: { kill: () => void; exitCode: number | null; signalCode: NodeJS.Signals | null }[] = []
411
+
327
412
  /**
328
413
  * @param browser - the detected installation to launch on first use.
329
414
  * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
330
415
  * @param extraArgs - additional Chromium switches.
331
416
  * @param ephemeralDir - a directory to delete on release, when the user has turned
332
417
  * persistence off so no login state outlives the session.
418
+ * @param launcher - how to start the browser, for tests.
419
+ *
420
+ * Recovery after the browser dies was untestable without this: the suite spawns a stub
421
+ * with Chromium's arguments, and a stub that is not a browser rejects them and exits, so
422
+ * no CDP endpoint ever appears and no session is ever created. Verified by mutation —
423
+ * removing every sessions.clear() left the recovery tests green. A launcher lets a test
424
+ * provide something that actually speaks CDP, so the state those tests describe exists.
333
425
  */
334
426
  constructor(
335
427
  private readonly browser: DetectedBrowser,
336
428
  private readonly profileDir: string,
337
429
  private readonly extraArgs: readonly string[] = [],
338
430
  private readonly ephemeralDir?: string,
431
+ private readonly launcher: BrowserLauncher = defaultLauncher,
339
432
  ) {}
340
433
 
341
434
  /** Which product this host would drive (for diagnostics). */
@@ -352,6 +445,36 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
352
445
  * The CDP client, starting the browser on first use.
353
446
  * @returns the connected client.
354
447
  */
448
+ /**
449
+ * Whether a client that reports itself open is actually answering.
450
+ *
451
+ * An open socket proves nothing: a browser can hold the connection and never reply, and
452
+ * that state must not be cached, or every later command waits out its own timeout. Asking
453
+ * the browser something cheap is a fact; reading the failure text is a guess, and the
454
+ * guess was wrong in both directions (it killed healthy browsers on a slow page, and it
455
+ * mistook the browser's own free text for a verdict).
456
+ *
457
+ * This is only reached when the socket is open, so the common path costs nothing.
458
+ * @param client - the client to question.
459
+ * @returns true when it answered.
460
+ */
461
+ private async probeClient(client: CdpClient): Promise<boolean> {
462
+ try {
463
+ // The command budget (30s) on purpose, not a shorter "probe" value, because of what the
464
+ // answer is used for: a false verdict discards the client and replaces the browser,
465
+ // which KILLS the window the human is looking at. A page that is merely slow must not
466
+ // lose its browser, so this errs toward waiting. The price is that a truly wedged
467
+ // browser takes the full budget to be declared dead — the tool call that triggered the
468
+ // probe answers first, and its timeout is the error the caller sees. Trading a later
469
+ // error for a window that is not destroyed is the right way round.
470
+ await client.send('Browser.getVersion', {})
471
+ return true
472
+ } catch {
473
+ // No answer within the budget, or the socket failed while asking: unusable either way.
474
+ return false
475
+ }
476
+ }
477
+
355
478
  private async ensureClient(): Promise<CdpClient> {
356
479
  // A disposed host must never start a browser: doing so would spawn a process
357
480
  // nobody owns and nobody will kill.
@@ -360,9 +483,26 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
360
483
  // (the browser closing its debugging endpoint, a dropped connection). Checking the
361
484
  // socket's own state costs nothing and turns a 30s hang into a restart.
362
485
  if (this.client !== undefined) {
363
- if (this.client.isAlive()) return this.client
364
- this.client.close()
486
+ // A closed socket means the browser is gone; an OPEN socket proves nothing, because
487
+ // a browser can hold the connection and never answer. Only probe when the socket is
488
+ // closed or a previous command went unanswered without being refused — probing every
489
+ // call would add a round trip to the common path for nothing.
490
+ const current = this.client
491
+ if (current.isAlive() && !this.clientSuspect) return current
492
+ if (current.isAlive() && await this.probeClient(current)) {
493
+ this.clientSuspect = false
494
+ return current
495
+ }
496
+ current.close()
365
497
  this.client = undefined
498
+ this.clientSuspect = false
499
+ // Everything this client issued dies with it. `start()` clears these too, but only
500
+ // inside its "kill the old child" branch — and the shape of this failure usually has no
501
+ // child left to kill: the process already exited, its exit listener already set
502
+ // `this.child` to undefined, so start() skips that branch and publishes a new browser
503
+ // while the views still point at the dead one. That is the state that never recovers.
504
+ this.views.clear()
505
+ this.sessions.clear()
366
506
  }
367
507
  // Concurrent first calls share one startup rather than racing two browsers.
368
508
  this.starting ??= this.start().finally(() => { this.starting = undefined })
@@ -391,7 +531,61 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
391
531
  ...this.extraArgs,
392
532
  'about:blank',
393
533
  ]
394
- const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
534
+ // A browser from a previous attempt may still be running with this same
535
+ // --user-data-dir. On Windows the new one would merely hand off to it and exit, and that
536
+ // exit would then be treated as the launch failing.
537
+ if (this.child !== undefined) {
538
+ const dying = this.child
539
+ try { dying.kill() } catch { /* already gone */ }
540
+ this.child = undefined
541
+
542
+ // Whatever the old browser issued is worthless now, and its exit listener cannot clean
543
+ // up after itself: this.child is reassigned below, so the old child's exit event returns
544
+ // early and the session ids it handed out would be reused against a browser that never
545
+ // heard of them — the "Session with given id not found" loop, which never clears.
546
+ this.client?.close()
547
+ this.client = undefined
548
+ this.views.clear()
549
+ this.sessions.clear()
550
+
551
+ // Wait for it to actually die. kill() only sends the signal: on Windows it is
552
+ // TerminateProcess, which is asynchronous, and the browser may still hold this profile's
553
+ // singleton lock for a while. Spawning immediately meant the replacement saw an existing
554
+ // instance, handed its command line over and exited — and that exit was then reported as
555
+ // "check that the path is a runnable browser", blaming the user's install for a race this
556
+ // code created. Bounded, because a process that refuses to die must not hang the call.
557
+ await new Promise<void>(resolve => {
558
+ if (dying.exitCode !== null || dying.signalCode !== null) { resolve(); return }
559
+ const done = (): void => { clearTimeout(timer); resolve() }
560
+ const timer = setTimeout(done, EXIT_GRACE_MS)
561
+ dying.once('exit', done)
562
+ // It did not exit in time. Dropping the reference here was how this host could
563
+ // launch a browser it could never kill again: dispose() only knows about this.child,
564
+ // and this process still holds the profile's singleton lock. Remember it so release
565
+ // still reaches it.
566
+ if (dying.exitCode === null && dying.signalCode === null) this.stubborn.push(dying)
567
+ dying.once('exit', () => {
568
+ const at2 = this.stubborn.indexOf(dying)
569
+ if (at2 !== -1) this.stubborn.splice(at2, 1)
570
+ })
571
+ })
572
+ }
573
+ // The port file belongs to the browser that just died, and it is NOT removed when the
574
+ // process is killed: nothing gives a terminated browser a chance to clean up. The launch
575
+ // loop below reads it to learn the port, so a stale file means connecting to whatever else
576
+ // holds that port — or to the previous instance — and publishing a client this host does
577
+ // not own and dispose() can never kill.
578
+ try { rmSync(join(this.profileDir, 'DevToolsActivePort'), { force: true }) } catch { /* nothing to remove */ }
579
+ const spawnedAt = Date.now()
580
+ let child: ReturnType<typeof spawn>
581
+ try {
582
+ child = this.launcher(this.browser.path, args)
583
+ } catch (error) {
584
+ // spawn throws synchronously for some targets (a .cmd on Windows, a path that cannot
585
+ // be executed). Without this the exception escaped start() and skipped every
586
+ // diagnostic below it.
587
+ throw new Error(`dsh-builtin-browser: could not launch ${this.browser.path}: ${error instanceof Error ? error.message : String(error)}`)
588
+ }
395
589
  this.child = child
396
590
  /**
397
591
  * A launch that fails (ENOENT, no permission, a broken binary) is reported by an
@@ -410,8 +604,31 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
410
604
  this.client?.close()
411
605
  this.client = undefined
412
606
  this.child = undefined
607
+ // The pages this client opened belong to the process that just died; keeping their
608
+ // session ids means every later command is sent to a browser that never issued them.
609
+ // Clearing these is what makes the restart usable rather than merely alive.
610
+ this.views.clear()
611
+ this.sessions.clear()
413
612
  })
414
613
  const portFile = join(this.profileDir, 'DevToolsActivePort')
614
+ /**
615
+ * The port this launch wrote, or undefined while it has not written one yet.
616
+ *
617
+ * A file left by the PREVIOUS browser is not evidence about this one. Its contents
618
+ * name a port that may now belong to something else entirely — another Chromium, a
619
+ * node --inspect — and connecting there publishes a client this host does not own and
620
+ * dispose() can never kill. Only a file written after the spawn counts.
621
+ */
622
+ const freshPort = (): number | undefined => {
623
+ try {
624
+ if (!existsSync(portFile)) return undefined
625
+ if (statSync(portFile).mtimeMs < spawnedAt) return undefined
626
+ const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
627
+ return Number.isInteger(value) && value > 0 ? value : undefined
628
+ } catch {
629
+ return undefined
630
+ }
631
+ }
415
632
  const deadline = Date.now() + 30_000
416
633
  /**
417
634
  * Why the loop stopped, so the failure can say what actually happened. Reporting
@@ -429,10 +646,11 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
429
646
  if (launchError !== undefined) { if (this.child === child) this.child = undefined; stopped = 'launch-failed'; break }
430
647
  if (Date.now() > deadline) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'timeout'; break }
431
648
  if (child.exitCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
432
- if (existsSync(portFile)) {
649
+ // Only a port THIS launch wrote: reading whatever file happened to be there is how a
650
+ // restart could adopt the previous browser, or any process now holding that port.
651
+ const port = freshPort()
652
+ if (port !== undefined) {
433
653
  try {
434
- const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
435
- if (port !== '') {
436
654
  const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() as { webSocketDebuggerUrl?: string }
437
655
  if (typeof version.webSocketDebuggerUrl === 'string') {
438
656
  const client = new CdpClient(version.webSocketDebuggerUrl)
@@ -443,7 +661,6 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
443
661
  this.client = client
444
662
  return client
445
663
  }
446
- }
447
664
  } catch {
448
665
  // The file can exist a moment before the port answers; poll again.
449
666
  }
@@ -458,7 +675,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
458
675
  throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`)
459
676
  }
460
677
  if (stopped === 'exited') {
461
- throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`)
678
+ // Exit code 0 is the whole story here: the browser decided to hand the request to an
679
+ // instance that already owns this profile directory and quit, rather than failing. That is
680
+ // what a leftover browser from a previous run (or a second DSH on the same profile) looks
681
+ // like. "Check that the path is a runnable browser" pointed at the one cause that is almost
682
+ // never it, and sent people looking for a broken install that was not there.
683
+ throw new Error(
684
+ `dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}). `
685
+ + 'That exit code means it handed the request to an instance already using this profile '
686
+ + 'directory and quit — look for a leftover browser process from an earlier run, then retry.',
687
+ )
462
688
  }
463
689
  if (stopped === 'released') {
464
690
  throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`)
@@ -489,24 +715,55 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
489
715
  // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
490
716
  // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
491
717
  // browser rather than hanging again, and the error names the cause instead of a timeout.
718
+ const client = await this.ensureClient()
719
+ const cachedView = this.views.get(viewId)
720
+ // A session belongs to the browser that issued it. Clearing the cache whenever a client is
721
+ // dropped covers the ordinary case, but not this one: another call can be inside
722
+ // ensureSession() holding the OLD client while this one discards it and clears, and that
723
+ // call's `views.set` then lands afterwards — re-adding an entry naming a browser that is
724
+ // gone. The identity check is what refuses it, and it costs a comparison rather than the
725
+ // round trip that validating every cached session against the browser would cost.
726
+ //
727
+ // (Not covered by the tests: the race needs two interleaved commands, and the suite's fake
728
+ // browser is synchronous enough that they never interleave. Verified by mutation instead —
729
+ // removing this check alone leaves every case green, which is why the comment says what it
730
+ // protects rather than claiming a case proves it.)
731
+ const session = cachedView !== undefined && cachedView.client === client
732
+ ? cachedView.session
733
+ : await this.ensureSession(viewId)
492
734
  try {
493
- const client = await this.ensureClient()
494
- const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
495
735
  const result = await client.send(method, params, session)
496
736
  return (result ?? {}) as Record<string, unknown>
497
- } catch (error) {
498
- this.client?.close()
499
- this.client = undefined
500
- throw error
737
+ } catch (error) {
738
+ // A CDP protocol error means the browser ANSWERED and refused this one command, so the
739
+ // connection is healthy and must be kept. Deciding by the error TEXT misfires both
740
+ // ways: the inner timeout rejects with "… timed out", so a slow but healthy page read
741
+ // as a dead browser, the client was dropped, and the next call's start() killed the
742
+ // running child — the human's tabs and half-filled forms gone, which is worse than the
743
+ // bug that change was fixing. The browser's own free text is interpolated into
744
+ // protocol errors too, so any message containing those words misfired the same way.
745
+ // Unanswered versus refused. 'timed out' is the inner client's own budget expiring,
746
+ // which says the page was slow, not that the browser died — so it only marks the client
747
+ // for a probe. Being wrong here costs a two-second check; being wrong about the
748
+ // browser being dead cost the user their open tabs.
749
+ if (error instanceof Error && /timed out/.test(error.message)) this.clientSuspect = true
750
+ if (!client.isAlive()) {
751
+ // Only THIS call's client. Under concurrency the field may already hold a
752
+ // connection another call just published, and closing that would kill a healthy one.
753
+ if (this.client === client) this.client = undefined
754
+ client.close()
755
+ }
756
+ throw error
501
757
  }
502
758
  },
503
759
  }
504
760
  }
505
761
 
506
762
  destroyView(handle: ElectronViewHandle): void {
507
- const session = this.views.get(handle.id)
763
+ const cachedView = this.views.get(handle.id)
508
764
  this.views.delete(handle.id)
509
- if (session === undefined) return
765
+ if (cachedView === undefined) return
766
+ const session = cachedView.session
510
767
  // A session id is not a target id: closing the page needs the target the session
511
768
  // was attached to, which is why they are tracked separately.
512
769
  const targetId = this.sessions.get(session)
@@ -563,21 +820,40 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
563
820
  this.sessions.clear()
564
821
  this.client?.close()
565
822
  try { this.child?.kill() } catch { /* already gone */ }
823
+ // And anything that ignored an earlier kill. They hold this profile, so leaving them
824
+ // running means the next launch contends with a browser nobody owns.
825
+ for (const leftover of this.stubborn.splice(0)) {
826
+ try { leftover.kill() } catch { /* already gone */ }
827
+ }
566
828
  const ephemeral = this.ephemeralDir
567
829
  if (ephemeral !== undefined && this.child !== undefined) {
568
830
  // The browser flushes its profile while shutting down, so deleting later avoids
569
831
  // racing it. Leftovers are only a stray temp directory, never a live credential.
570
- setTimeout(() => { try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ } }, 3_000).unref?.()
832
+ //
833
+ // The timer is unref'd, so it never holds the process open — which also means that a
834
+ // process which exits inside this 3s window (a CLI probe, a DSH restart) leaves the
835
+ // directory behind for good, silently contradicting the user's "keep no profile"
836
+ // choice. The exit hook below is the same delete, run synchronously as a last resort;
837
+ // the callback is idempotent, so whichever runs first wins and the other is a no-op.
838
+ const removeEphemeral = (): void => {
839
+ try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
840
+ }
841
+ setTimeout(removeEphemeral, 3_000).unref?.()
842
+ process.once('exit', removeEphemeral)
571
843
  } else if (ephemeral !== undefined) {
572
844
  // Never started, so nothing can be holding it open.
573
845
  try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
574
846
  }
575
847
  }
576
848
 
577
- /** The flattened session for a view, creating its page on first use. */
849
+ /**
850
+ * The flattened session for a view, creating its page on first use.
851
+ *
852
+ * No cache check here: the only caller has already established that the cached session does
853
+ * not belong to the client it is about to use. Re-checking would mean either a round trip or
854
+ * a second copy of that rule.
855
+ */
578
856
  private async ensureSession(viewId: string): Promise<string> {
579
- const existing = this.views.get(viewId)
580
- if (existing !== undefined) return existing
581
857
  const client = await this.ensureClient()
582
858
  const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
583
859
  const targetId = created?.targetId
@@ -587,7 +863,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
587
863
  if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
588
864
  // The session id is what page commands ride; the target id is what closes it.
589
865
  this.sessions.set(sessionId, targetId)
590
- this.views.set(viewId, sessionId)
866
+ this.views.set(viewId, { session: sessionId, client })
591
867
  return sessionId
592
868
  }
593
869