dsh-builtin-browser 0.4.1 → 0.4.5

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 (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -25,15 +25,57 @@
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
+ * Ceiling on one attempt to ask a discovered CDP port who it is.
44
+ *
45
+ * The launch loop below polls: it reads `DevToolsActivePort`, tries `/json/version` on the port
46
+ * inside it, and waits 250 ms between attempts under a 30 s deadline. An unbounded fetch can
47
+ * park inside one iteration for the whole budget, so a bound is needed — but it has to be
48
+ * small enough that the loop still behaves like a loop.
49
+ *
50
+ * The usual case is NOT a slow answer, it is NO answer: a browser that has written the file but
51
+ * is not listening yet refuses the connection instantly, and that immediacy is what lets the
52
+ * loop spin. Any timeout added here is paid on every iteration in which the port is not ready,
53
+ * so an earlier attempt at this used 2000 ms — eight times the poll interval — and cut the
54
+ * attempts available in the budget from roughly 120 to 15. This value is the same size as the
55
+ * interval, so a worst-case iteration costs 500 ms and the budget still affords ~60 attempts.
56
+ */
57
+ const PORT_PROBE_MS = 250
58
+
59
+ /**
60
+ * Starts a browser process and returns a handle to it.
61
+ *
62
+ * Injectable so tests can supply something that speaks CDP — the seam a recovery test needs
63
+ * and could not have while the launch was hardcoded to a stub that rejects Chromium's args.
64
+ * @param path - the executable to run.
65
+ * @param args - its arguments.
66
+ * @returns the child process.
67
+ */
68
+ export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>
69
+
70
+ /** The real launcher: spawn a detached-free child with pipes closed. */
71
+ const defaultLauncher: BrowserLauncher = (path, args) =>
72
+ spawn(path, [...args], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
31
73
  import { spawn, type ChildProcess } from 'node:child_process'
32
74
  import { randomUUID } from 'node:crypto'
33
75
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
34
76
 
35
77
  /** Which browser the user asked for. */
36
- export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto'
78
+ export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar'
37
79
 
38
80
  /** A resolved browser installation. */
39
81
  export interface DetectedBrowser {
@@ -83,6 +125,11 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
83
125
  win32: [
84
126
  String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
85
127
  String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
128
+ // Where a per-user install lands, which is what Chrome's own installer chooses when it
129
+ // cannot (or should not) write to Program Files. Missing this meant the plugin reported
130
+ // "no browser found" on machines that plainly had one.
131
+ String.raw`%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`,
132
+ String.raw`%LOCALAPPDATA%\Chromium\Application\chrome.exe`,
86
133
  ],
87
134
  darwin: [
88
135
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -100,6 +147,7 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
100
147
  win32: [
101
148
  String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
102
149
  String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
150
+ String.raw`%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe`,
103
151
  ],
104
152
  darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
105
153
  linux: [
@@ -112,12 +160,29 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
112
160
  win32: [
113
161
  String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
114
162
  String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
163
+ String.raw`%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe`,
115
164
  ],
116
165
  darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
117
166
  linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
118
167
  },
119
168
  }
120
169
 
170
+ /**
171
+ * Resolve the environment references in one install location.
172
+ *
173
+ * The list is static strings, but a per-user install lands under %LOCALAPPDATA%, so the entry
174
+ * has to be resolved against the environment detection was HANDED rather than process.env — a
175
+ * caller that supplies a platform and an environment (which is what makes detection testable)
176
+ * must not end up reading the developer's real machine.
177
+ * @param location - one entry from the install-location table.
178
+ * @param env - the environment to resolve against.
179
+ * @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
180
+ */
181
+ function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
182
+ return location.replace(/%([^%]+)%/g, (whole, name: string) =>
183
+ env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
184
+ }
185
+
121
186
  /**
122
187
  * Find an executable by name on PATH.
123
188
  *
@@ -172,7 +237,8 @@ export function detectBrowser(
172
237
  if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
173
238
  const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform)
174
239
  if (onPath !== undefined) return { kind, path: onPath }
175
- for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
240
+ for (const raw of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
241
+ const candidate = expandLocation(raw, env)
176
242
  if (existsSync(candidate)) return { kind, path: candidate }
177
243
  }
178
244
  }
@@ -189,9 +255,15 @@ export function detectBrowser(
189
255
  * @param platform - the platform whose names and locations apply.
190
256
  * @returns the launcher names (as PATH lookups) and the fixed locations checked.
191
257
  */
192
- export function searchSummary(kind: DetectedBrowser['kind'], platform: NodeJS.Platform = process.platform): string[] {
258
+ export function searchSummary(
259
+ kind: DetectedBrowser['kind'],
260
+ platform: NodeJS.Platform = process.platform,
261
+ env: NodeJS.ProcessEnv = process.env,
262
+ ): string[] {
193
263
  const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`)
194
- const located = INSTALL_LOCATIONS[kind]?.[platform] ?? []
264
+ // Expanded, so the report names the location that was actually tested rather than a template
265
+ // the user cannot paste into a file manager.
266
+ const located = (INSTALL_LOCATIONS[kind]?.[platform] ?? []).map(entry => expandLocation(entry, env))
195
267
  return [...onPath, ...located]
196
268
  }
197
269
 
@@ -310,7 +382,19 @@ class CdpClient {
310
382
 
311
383
  /** A system browser driven over CDP, presented as a browser view host. */
312
384
  export class SystemBrowserViewHost implements ElectronBrowserViewHost {
313
- private readonly views = new Map<string, string>()
385
+ /**
386
+ * viewId -> the session that view rides, and the client that issued it.
387
+ *
388
+ * The client is part of the value on purpose. A session id means nothing to a browser other
389
+ * than the one that handed it out, and this host replaces browsers routinely: the process
390
+ * dies, or its debugging socket closes while the process lives on. Keeping only the id meant
391
+ * the host could start a NEW browser successfully and then send every command to it carrying
392
+ * a session the old one had issued — `Session with given id not found`, on every call, with a
393
+ * browser that answers /json/version perfectly well and nothing the user can do short of
394
+ * restarting DSH. Comparing identity catches it with no round trip; validating each cached
395
+ * session against the browser would put one on every page command.
396
+ */
397
+ private readonly views = new Map<string, { session: string; client: CdpClient }>()
314
398
  private disposed = false
315
399
  /**
316
400
  * The browser is started on FIRST USE, never on construction.
@@ -321,21 +405,47 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
321
405
  * spawned when a view is actually needed.
322
406
  */
323
407
  private client: CdpClient | undefined
408
+ /**
409
+ * Set when a command went unanswered rather than being refused.
410
+ *
411
+ * A timeout says nothing about the connection: the page may simply be slow. It marks the
412
+ * client for a cheap check before the next command trusts it, which keeps a slow page
413
+ * from being mistaken for a dead browser while still catching one that has truly stopped
414
+ * answering.
415
+ */
416
+ private clientSuspect = false
324
417
  private child: ChildProcess | undefined
325
418
  private starting: Promise<CdpClient> | undefined
326
419
 
420
+ /**
421
+ * Browsers that ignored a kill.
422
+ *
423
+ * They are no longer this host's child — a replacement has taken that slot — but they
424
+ * are still running, still holding the profile, and still something release must try to
425
+ * stop. Forgetting them is what allowed a launch this host could not undo.
426
+ */
427
+ private readonly stubborn: { kill: () => void; exitCode: number | null; signalCode: NodeJS.Signals | null }[] = []
428
+
327
429
  /**
328
430
  * @param browser - the detected installation to launch on first use.
329
431
  * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
330
432
  * @param extraArgs - additional Chromium switches.
331
433
  * @param ephemeralDir - a directory to delete on release, when the user has turned
332
434
  * persistence off so no login state outlives the session.
435
+ * @param launcher - how to start the browser, for tests.
436
+ *
437
+ * Recovery after the browser dies was untestable without this: the suite spawns a stub
438
+ * with Chromium's arguments, and a stub that is not a browser rejects them and exits, so
439
+ * no CDP endpoint ever appears and no session is ever created. Verified by mutation —
440
+ * removing every sessions.clear() left the recovery tests green. A launcher lets a test
441
+ * provide something that actually speaks CDP, so the state those tests describe exists.
333
442
  */
334
443
  constructor(
335
444
  private readonly browser: DetectedBrowser,
336
445
  private readonly profileDir: string,
337
446
  private readonly extraArgs: readonly string[] = [],
338
447
  private readonly ephemeralDir?: string,
448
+ private readonly launcher: BrowserLauncher = defaultLauncher,
339
449
  ) {}
340
450
 
341
451
  /** Which product this host would drive (for diagnostics). */
@@ -352,6 +462,49 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
352
462
  * The CDP client, starting the browser on first use.
353
463
  * @returns the connected client.
354
464
  */
465
+ /**
466
+ * Whether a client that reports itself open is actually answering.
467
+ *
468
+ * An open socket proves nothing: a browser can hold the connection and never reply, and
469
+ * that state must not be cached, or every later command waits out its own timeout. Asking
470
+ * the browser something cheap is a fact; reading the failure text is a guess, and the
471
+ * guess was wrong in both directions (it killed healthy browsers on a slow page, and it
472
+ * mistook the browser's own free text for a verdict).
473
+ *
474
+ * This is only reached when the socket is open, so the common path costs nothing.
475
+ * @param client - the client to question.
476
+ * @returns true when it answered.
477
+ */
478
+ private async probeClient(client: CdpClient): Promise<boolean> {
479
+ try {
480
+ // The command budget (30s) on purpose, not a shorter "probe" value, because of what the
481
+ // answer is used for: a false verdict discards the client and replaces the browser,
482
+ // which KILLS the window the human is looking at. A page that is merely slow must not
483
+ // lose its browser, so this errs toward waiting. The price is that a truly wedged
484
+ // browser takes the full budget to be declared dead — the tool call that triggered the
485
+ // probe answers first, and its timeout is the error the caller sees. Trading a later
486
+ // error for a window that is not destroyed is the right way round.
487
+ await client.send('Browser.getVersion', {})
488
+ return true
489
+ } catch {
490
+ // No answer within the budget, or the socket failed while asking: unusable either way.
491
+ return false
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Forget which browser issued what, after that browser is gone.
497
+ *
498
+ * The two maps must move together: `views` maps a view id to a session id and `sessions`
499
+ * maps a session id to a target id, so clearing one alone leaves a mapping the next command
500
+ * will act on. Every path that abandons a browser calls this rather than spelling the pair
501
+ * out — four separate copies is how the third one came to be missing.
502
+ */
503
+ private forgetBrowserState(): void {
504
+ this.views.clear()
505
+ this.sessions.clear()
506
+ }
507
+
355
508
  private async ensureClient(): Promise<CdpClient> {
356
509
  // A disposed host must never start a browser: doing so would spawn a process
357
510
  // nobody owns and nobody will kill.
@@ -360,9 +513,25 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
360
513
  // (the browser closing its debugging endpoint, a dropped connection). Checking the
361
514
  // socket's own state costs nothing and turns a 30s hang into a restart.
362
515
  if (this.client !== undefined) {
363
- if (this.client.isAlive()) return this.client
364
- this.client.close()
516
+ // A closed socket means the browser is gone; an OPEN socket proves nothing, because
517
+ // a browser can hold the connection and never answer. Only probe when the socket is
518
+ // closed or a previous command went unanswered without being refused — probing every
519
+ // call would add a round trip to the common path for nothing.
520
+ const current = this.client
521
+ if (current.isAlive() && !this.clientSuspect) return current
522
+ if (current.isAlive() && await this.probeClient(current)) {
523
+ this.clientSuspect = false
524
+ return current
525
+ }
526
+ current.close()
365
527
  this.client = undefined
528
+ this.clientSuspect = false
529
+ // Everything this client issued dies with it. `start()` clears these too, but only
530
+ // inside its "kill the old child" branch — and the shape of this failure usually has no
531
+ // child left to kill: the process already exited, its exit listener already set
532
+ // `this.child` to undefined, so start() skips that branch and publishes a new browser
533
+ // while the views still point at the dead one. That is the state that never recovers.
534
+ this.forgetBrowserState()
366
535
  }
367
536
  // Concurrent first calls share one startup rather than racing two browsers.
368
537
  this.starting ??= this.start().finally(() => { this.starting = undefined })
@@ -391,7 +560,60 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
391
560
  ...this.extraArgs,
392
561
  'about:blank',
393
562
  ]
394
- const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
563
+ // A browser from a previous attempt may still be running with this same
564
+ // --user-data-dir. On Windows the new one would merely hand off to it and exit, and that
565
+ // exit would then be treated as the launch failing.
566
+ if (this.child !== undefined) {
567
+ const dying = this.child
568
+ try { dying.kill() } catch { /* already gone */ }
569
+ this.child = undefined
570
+
571
+ // Whatever the old browser issued is worthless now, and its exit listener cannot clean
572
+ // up after itself: this.child is reassigned below, so the old child's exit event returns
573
+ // early and the session ids it handed out would be reused against a browser that never
574
+ // heard of them — the "Session with given id not found" loop, which never clears.
575
+ this.client?.close()
576
+ this.client = undefined
577
+ this.forgetBrowserState()
578
+
579
+ // Wait for it to actually die. kill() only sends the signal: on Windows it is
580
+ // TerminateProcess, which is asynchronous, and the browser may still hold this profile's
581
+ // singleton lock for a while. Spawning immediately meant the replacement saw an existing
582
+ // instance, handed its command line over and exited — and that exit was then reported as
583
+ // "check that the path is a runnable browser", blaming the user's install for a race this
584
+ // code created. Bounded, because a process that refuses to die must not hang the call.
585
+ await new Promise<void>(resolve => {
586
+ if (dying.exitCode !== null || dying.signalCode !== null) { resolve(); return }
587
+ const done = (): void => { clearTimeout(timer); resolve() }
588
+ const timer = setTimeout(done, EXIT_GRACE_MS)
589
+ dying.once('exit', done)
590
+ // It did not exit in time. Dropping the reference here was how this host could
591
+ // launch a browser it could never kill again: dispose() only knows about this.child,
592
+ // and this process still holds the profile's singleton lock. Remember it so release
593
+ // still reaches it.
594
+ if (dying.exitCode === null && dying.signalCode === null) this.stubborn.push(dying)
595
+ dying.once('exit', () => {
596
+ const at2 = this.stubborn.indexOf(dying)
597
+ if (at2 !== -1) this.stubborn.splice(at2, 1)
598
+ })
599
+ })
600
+ }
601
+ // The port file belongs to the browser that just died, and it is NOT removed when the
602
+ // process is killed: nothing gives a terminated browser a chance to clean up. The launch
603
+ // loop below reads it to learn the port, so a stale file means connecting to whatever else
604
+ // holds that port — or to the previous instance — and publishing a client this host does
605
+ // not own and dispose() can never kill.
606
+ try { rmSync(join(this.profileDir, 'DevToolsActivePort'), { force: true }) } catch { /* nothing to remove */ }
607
+ const spawnedAt = Date.now()
608
+ let child: ReturnType<typeof spawn>
609
+ try {
610
+ child = this.launcher(this.browser.path, args)
611
+ } catch (error) {
612
+ // spawn throws synchronously for some targets (a .cmd on Windows, a path that cannot
613
+ // be executed). Without this the exception escaped start() and skipped every
614
+ // diagnostic below it.
615
+ throw new Error(`dsh-builtin-browser: could not launch ${this.browser.path}: ${error instanceof Error ? error.message : String(error)}`)
616
+ }
395
617
  this.child = child
396
618
  /**
397
619
  * A launch that fails (ENOENT, no permission, a broken binary) is reported by an
@@ -410,8 +632,30 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
410
632
  this.client?.close()
411
633
  this.client = undefined
412
634
  this.child = undefined
635
+ // The pages this client opened belong to the process that just died; keeping their
636
+ // session ids means every later command is sent to a browser that never issued them.
637
+ // Clearing these is what makes the restart usable rather than merely alive.
638
+ this.forgetBrowserState()
413
639
  })
414
640
  const portFile = join(this.profileDir, 'DevToolsActivePort')
641
+ /**
642
+ * The port this launch wrote, or undefined while it has not written one yet.
643
+ *
644
+ * A file left by the PREVIOUS browser is not evidence about this one. Its contents
645
+ * name a port that may now belong to something else entirely — another Chromium, a
646
+ * node --inspect — and connecting there publishes a client this host does not own and
647
+ * dispose() can never kill. Only a file written after the spawn counts.
648
+ */
649
+ const freshPort = (): number | undefined => {
650
+ try {
651
+ if (!existsSync(portFile)) return undefined
652
+ if (statSync(portFile).mtimeMs < spawnedAt) return undefined
653
+ const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
654
+ return Number.isInteger(value) && value > 0 ? value : undefined
655
+ } catch {
656
+ return undefined
657
+ }
658
+ }
415
659
  const deadline = Date.now() + 30_000
416
660
  /**
417
661
  * Why the loop stopped, so the failure can say what actually happened. Reporting
@@ -428,14 +672,29 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
428
672
  if (this.disposed) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'released'; break }
429
673
  if (launchError !== undefined) { if (this.child === child) this.child = undefined; stopped = 'launch-failed'; break }
430
674
  if (Date.now() > deadline) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'timeout'; break }
431
- if (child.exitCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
432
- if (existsSync(portFile)) {
675
+ // signalCode too: a browser killed by a signal never sets exitCode, so checking exitCode
676
+ // alone made a killed browser look alive and the launch run out its full 30s deadline
677
+ // before reporting "did not expose CDP" — a timeout for something that died at once.
678
+ // The sibling check on the release path already looked at both.
679
+ if (child.exitCode !== null || child.signalCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
680
+ // Only a port THIS launch wrote: reading whatever file happened to be there is how a
681
+ // restart could adopt the previous browser, or any process now holding that port.
682
+ const port = freshPort()
683
+ if (port !== undefined) {
433
684
  try {
434
- const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
435
- if (port !== '') {
436
- const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() as { webSocketDebuggerUrl?: string }
685
+ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
686
+ // Bounded: an unanswered loopback request would otherwise park inside this
687
+ // iteration for the rest of the budget, and the deadline above is only re-checked
688
+ // between iterations. Deliberately no larger than the 250 ms sleep — see
689
+ // PORT_PROBE_MS.
690
+ signal: AbortSignal.timeout(PORT_PROBE_MS),
691
+ })).json() as { webSocketDebuggerUrl?: string }
437
692
  if (typeof version.webSocketDebuggerUrl === 'string') {
438
693
  const client = new CdpClient(version.webSocketDebuggerUrl)
694
+ // `whenReady` has its own 30 s ceiling, and it is reached only after the port
695
+ // answered above — so the browser is listening and this is a handshake, not a
696
+ // discovery. A handshake that outlives the launch is reported as the timeout it
697
+ // is, by the same deadline that governs everything else here.
439
698
  await client.whenReady()
440
699
  // Still wanted? A dispose() during the handshake has already killed the
441
700
  // process, so publishing the client would leave a dangling connection.
@@ -443,7 +702,6 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
443
702
  this.client = client
444
703
  return client
445
704
  }
446
- }
447
705
  } catch {
448
706
  // The file can exist a moment before the port answers; poll again.
449
707
  }
@@ -458,7 +716,24 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
458
716
  throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`)
459
717
  }
460
718
  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`)
719
+ // Only exit code 0 is the hand-off story. A non-zero code or a signal means the browser
720
+ // died on its own — a crash, a missing library, a sandbox refusal — and asserting the
721
+ // profile story for those sent people looking for a leftover process that was not there,
722
+ // which is the same mistake this message was written to correct, pointing the other way.
723
+ const code = child.exitCode
724
+ const signal = child.signalCode
725
+ const clean = code === 0 && signal === null
726
+ throw new Error(
727
+ clean
728
+ ? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
729
+ + 'That means it handed the request to an instance already using this profile '
730
+ + 'directory and quit — look for a leftover browser process from an earlier run, then retry.'
731
+ : `dsh-builtin-browser: ${target} exited immediately after launch `
732
+ + `(${signal !== null ? `killed by ${signal}` : `exit code ${String(code)}`}), so it did not `
733
+ + 'hand the request anywhere — the browser failed to start rather than deferring to another '
734
+ + 'instance. Check that it can run at all: run it by hand, and look for a crash or a missing '
735
+ + 'dependency rather than a leftover process.',
736
+ )
462
737
  }
463
738
  if (stopped === 'released') {
464
739
  throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`)
@@ -489,24 +764,65 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
489
764
  // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
490
765
  // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
491
766
  // browser rather than hanging again, and the error names the cause instead of a timeout.
767
+ const client = await this.ensureClient()
768
+ const cachedView = this.views.get(viewId)
769
+ // A session belongs to the browser that issued it. Clearing the cache whenever a client is
770
+ // dropped covers the ordinary case, but not this one: another call can be inside
771
+ // ensureSession() holding the OLD client while this one discards it and clears, and that
772
+ // call's `views.set` then lands afterwards — re-adding an entry naming a browser that is
773
+ // gone. The identity check is what refuses it, and it costs a comparison rather than the
774
+ // round trip that validating every cached session against the browser would cost.
775
+ //
776
+ // (Not covered by the tests: the race needs two interleaved commands, and the suite's fake
777
+ // browser is synchronous enough that they never interleave. Verified by mutation instead —
778
+ // removing this check alone leaves every case green, which is why the comment says what it
779
+ // protects rather than claiming a case proves it.)
780
+ const session = cachedView !== undefined && cachedView.client === client
781
+ ? cachedView.session
782
+ : await this.ensureSession(viewId)
492
783
  try {
493
- const client = await this.ensureClient()
494
- const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
495
784
  const result = await client.send(method, params, session)
496
785
  return (result ?? {}) as Record<string, unknown>
497
- } catch (error) {
498
- this.client?.close()
499
- this.client = undefined
500
- throw error
786
+ } catch (error) {
787
+ // A CDP protocol error means the browser ANSWERED and refused this one command, so the
788
+ // connection is healthy and must be kept. Deciding by the error TEXT misfires both
789
+ // ways: the inner timeout rejects with "… timed out", so a slow but healthy page read
790
+ // as a dead browser, the client was dropped, and the next call's start() killed the
791
+ // running child — the human's tabs and half-filled forms gone, which is worse than the
792
+ // bug that change was fixing. The browser's own free text is interpolated into
793
+ // protocol errors too, so any message containing those words misfired the same way.
794
+ // Unanswered versus refused. 'timed out' is the inner client's own budget expiring,
795
+ // which says the page was slow, not that the browser died — so it only marks the client
796
+ // for a probe. Being wrong here costs a two-second check; being wrong about the
797
+ // browser being dead cost the user their open tabs.
798
+ if (error instanceof Error && /timed out/.test(error.message)) this.clientSuspect = true
799
+ if (!client.isAlive()) {
800
+ // Only THIS call's client. Under concurrency the field may already hold a
801
+ // connection another call just published, and closing that would kill a healthy one.
802
+ if (this.client === client) {
803
+ this.client = undefined
804
+ // This is the third of the three paths that discard a client, and it was the one
805
+ // that did not retire what the client had issued. A view holding a session id
806
+ // from the dead browser kept handing it to the replacement, which had never heard
807
+ // of it: every page command then failed with "Session with given id not found"
808
+ // and no later call could clear it, because the cached session was returned
809
+ // unconditionally. Gated on the same identity check as the field itself, so a
810
+ // concurrent call that just published a healthy client does not lose its maps.
811
+ this.forgetBrowserState()
812
+ }
813
+ client.close()
814
+ }
815
+ throw error
501
816
  }
502
817
  },
503
818
  }
504
819
  }
505
820
 
506
821
  destroyView(handle: ElectronViewHandle): void {
507
- const session = this.views.get(handle.id)
822
+ const cachedView = this.views.get(handle.id)
508
823
  this.views.delete(handle.id)
509
- if (session === undefined) return
824
+ if (cachedView === undefined) return
825
+ const session = cachedView.session
510
826
  // A session id is not a target id: closing the page needs the target the session
511
827
  // was attached to, which is why they are tracked separately.
512
828
  const targetId = this.sessions.get(session)
@@ -521,11 +837,32 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
521
837
  /** Nothing to show: the browser owns its own windows. */
522
838
  showView(): void {}
523
839
 
524
- /** Same as {@link showView}. */
525
- async presentView(): Promise<void> {}
840
+ /**
841
+ * Bring the target's page to the front, then report success.
842
+ *
843
+ * The comment on `focus` below used to say this was impossible — that raising the page needs
844
+ * `Page.bringToFront` on a target session, which is a view-level concern the host cannot
845
+ * reach. The handle this method is handed IS that view, and it can send commands, so the
846
+ * capability was here all along; it just was not used. Without it `presentView` was an empty
847
+ * async method, so the provider's whole presentation barrier — the one that stops
848
+ * `Input.*` from being silently dropped for a view with no display surface, and reports
849
+ * `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
850
+ *
851
+ * A failure is swallowed rather than propagated: a real Chrome window that refuses to come
852
+ * forward is not a reason to refuse the click that follows. The barrier exists to order the
853
+ * two, and the order is honoured either way.
854
+ * @param handle - the view to present.
855
+ */
856
+ async presentView(handle: ElectronViewHandle): Promise<void> {
857
+ try {
858
+ await handle.sendCommand('Page.bringToFront')
859
+ } catch {
860
+ // Backgrounded, minimised, or a target that went away between the snapshot and the
861
+ // click. The command that called this will report its own failure if the page is gone.
862
+ }
863
+ }
864
+
526
865
 
527
- /** The browser owns window grouping. */
528
- groupView(): void {}
529
866
 
530
867
  /**
531
868
  * Best effort at bringing the browser's window forward.
@@ -543,11 +880,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
543
880
  try { this.child?.kill('SIGCONT') } catch { /* Windows has no SIGCONT; nothing to do */ }
544
881
  }
545
882
 
546
- /** The browser reports its own window lifecycle. */
547
- onUserAction(): void {}
548
883
 
549
- /** The browser reports its own window lifecycle. */
550
- onViewClosed(): void {}
551
884
 
552
885
  /**
553
886
  * Close the browser we launched, if we ever launched one.
@@ -559,25 +892,58 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
559
892
  dispose(): void {
560
893
  if (this.disposed) return
561
894
  this.disposed = true
562
- this.views.clear()
563
- this.sessions.clear()
895
+ this.forgetBrowserState()
564
896
  this.client?.close()
565
897
  try { this.child?.kill() } catch { /* already gone */ }
898
+ // And anything that ignored an earlier kill. They hold this profile, so leaving them
899
+ // running means the next launch contends with a browser nobody owns.
900
+ for (const leftover of this.stubborn.splice(0)) {
901
+ try { leftover.kill() } catch { /* already gone */ }
902
+ }
566
903
  const ephemeral = this.ephemeralDir
567
904
  if (ephemeral !== undefined && this.child !== undefined) {
568
905
  // The browser flushes its profile while shutting down, so deleting later avoids
569
906
  // 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?.()
907
+ //
908
+ // The timer is unref'd, so it never holds the process open — which also means that a
909
+ // process which exits inside this 3s window (a CLI probe, a DSH restart) leaves the
910
+ // directory behind for good, silently contradicting the user's "keep no profile"
911
+ // choice. The exit hook below is the same delete, run synchronously as a last resort;
912
+ // the callback is idempotent, so whichever runs first wins and the other is a no-op.
913
+ const removeEphemeral = (): void => {
914
+ try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
915
+ }
916
+ setTimeout(removeEphemeral, 3_000).unref?.()
917
+ process.once('exit', removeEphemeral)
571
918
  } else if (ephemeral !== undefined) {
572
919
  // Never started, so nothing can be holding it open.
573
920
  try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
574
921
  }
575
922
  }
576
923
 
577
- /** The flattened session for a view, creating its page on first use. */
924
+ /**
925
+ * The flattened session for a view, creating its page on first use.
926
+ *
927
+ * No cache check here: the only caller has already established that the cached session does
928
+ * not belong to the client it is about to use. Re-checking would mean either a round trip or
929
+ * a second copy of that rule.
930
+ */
578
931
  private async ensureSession(viewId: string): Promise<string> {
579
- const existing = this.views.get(viewId)
580
- if (existing !== undefined) return existing
932
+ // One target per view, even when two commands arrive together. The two CDP round trips
933
+ // below are awaits, so without this a second caller entering during the first's flight saw
934
+ // no session in `views` and created its own target — leaving an orphan page that nothing
935
+ // closes, because only the last id survives in the map.
936
+ const inFlight = this.sessionBuilding.get(viewId)
937
+ if (inFlight !== undefined) return await inFlight
938
+ const building = this.buildSession(viewId).finally(() => {
939
+ this.sessionBuilding.delete(viewId)
940
+ })
941
+ this.sessionBuilding.set(viewId, building)
942
+ return await building
943
+ }
944
+
945
+ /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
946
+ private async buildSession(viewId: string): Promise<string> {
581
947
  const client = await this.ensureClient()
582
948
  const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
583
949
  const targetId = created?.targetId
@@ -587,10 +953,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
587
953
  if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
588
954
  // The session id is what page commands ride; the target id is what closes it.
589
955
  this.sessions.set(sessionId, targetId)
590
- this.views.set(viewId, sessionId)
956
+ this.views.set(viewId, { session: sessionId, client })
591
957
  return sessionId
592
958
  }
593
959
 
960
+ /** Views whose session is being created right now, so a second caller waits for the first. */
961
+ private readonly sessionBuilding = new Map<string, Promise<string>>()
962
+
594
963
  /** sessionId -> targetId, so a destroyed view can close the right page. */
595
964
  private readonly sessions = new Map<string, string>()
596
965
  }