dsh-builtin-browser 0.1.19 → 0.1.21

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.
@@ -29,7 +29,7 @@ import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_proces
29
29
  import { randomBytes } from 'node:crypto'
30
30
  import { createRequire } from 'node:module'
31
31
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
32
- import { basename, dirname, join } from 'node:path'
32
+ import { basename, dirname, isAbsolute, join } from 'node:path'
33
33
  import { createServer, type Server, type Socket } from 'node:net'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import type { BrowserUserAction, ElectronBrowserViewHost, ElectronViewHandle, ScreenshotOptions } from './provider.js'
@@ -42,56 +42,110 @@ const READY_TIMEOUT_MS = 20_000
42
42
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
43
43
 
44
44
  /**
45
- * Locate the Electron binary:
46
- * 0. the current process IS Electron (DSH Desktop hosts load this plugin
47
- * inside their own Electron main process) — reuse the host binary, so
48
- * the plugin works out of the box without `add electron`;
49
- * 1. require('electron') from this module (bundled dependency — the
50
- * electron package ships with the plugin),
51
- * 2. ELECTRON_PATH (explicit override),
52
- * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
53
- * NEWEST version found. Older Electron releases (e.g. 33.x) have a
54
- * compositor defect that intermittently breaks page capture, so prefer
55
- * the newest binary available in the environment,
45
+ * Locate a SPAWNABLE Electron binary — one that runs a script given as an
46
+ * argument, i.e. a BARE electron, never a packaged app executable:
47
+ * 0. ELECTRON_PATH (explicit override — user intent beats auto-discovery),
48
+ * 1. the plugin's own electron package (a real dependency since 0.1.18),
49
+ * found by a pure filesystem walk — never require(), whose semantics
50
+ * differ inside an Electron main process (there it is the built-in API
51
+ * module, not the npm package) and which would trigger electron 44+'s
52
+ * lazy binary download;
53
+ * 2. every DSH install anchor (electron installed separately, e.g.
54
+ * `dsh plugin add electron` into the profile or a global prefix),
55
+ * choosing the NEWEST version found. Older Electron releases (e.g.
56
+ * 33.x) have a compositor defect that intermittently breaks page
57
+ * capture, so prefer the newest binary available in the environment,
58
+ * 3. the current process, when it IS a bare Electron (dev hosts, e.g.
59
+ * `electron .`): reuse the host binary. Packaged app executables
60
+ * (resources/app.asar beside the exe) are never reused — they ignore
61
+ * the script argument and launch the app itself, typically exiting
62
+ * immediately via the single-instance lock, which is exactly the
63
+ * "browser host exited (code=0)" failure on DSH Desktop;
56
64
  * 4. last resort: the process ancestry, for hosts that run this plugin in a
57
- * child Node process of their own Electron main process (DSH Desktop).
65
+ * child Node process of their own bare-Electron main process.
58
66
  * @returns the path to the Electron executable.
59
67
  */
68
+ /** The discovery inputs {@link resolveElectronPathImpl} selects over, injectable for tests. */
69
+ interface ElectronPathInputs {
70
+ /** ELECTRON_PATH (may be unset, empty, or pointing at a missing file). */
71
+ readonly override: string | undefined
72
+ /** True when running inside an Electron main process. */
73
+ readonly inElectron: boolean
74
+ /** process.execPath (candidate for in-process host reuse). */
75
+ readonly execPath: string
76
+ /** The plugin's own electron package probe (bundled). */
77
+ readonly bundled: () => string | undefined
78
+ /** The DSH install-anchor probe. */
79
+ readonly anchored: () => string | undefined
80
+ /** The process-ancestry probe (last resort). */
81
+ readonly ancestry: () => string | undefined
82
+ }
83
+
60
84
  function resolveElectronPath(): string {
61
- const require = createRequire(import.meta.url)
62
- // 0. The current process IS Electron: reuse the host binary. It cannot be
63
- // missing because we are running on it.
64
- if (typeof process.versions.electron === 'string') {
65
- const exe = process.execPath
66
- if (typeof exe === 'string' && exe.length > 0 && existsSync(exe)) return exe
67
- }
68
- // 1. Bundled dependency — but ONLY when its binary is already on disk.
69
- // Electron 44+ downloads the binary lazily on the first require()
70
- // (it has no postinstall anymore), so probing here must never trigger a
71
- // synchronous network download: available() would block DSH startup for
72
- // the download (or fail outright offline), surfacing as a broken plugin.
73
- // The download still happens on the first real spawn; this guard only
74
- // keeps the probe side-effect free.
75
- try {
76
- const entry: unknown = require.resolve('electron')
77
- if (typeof entry === 'string' && entry.length > 0) {
78
- const pkgRoot = dirname(entry)
79
- const binaryReady =
80
- existsSync(join(pkgRoot, 'path.txt'))
81
- || existsSync(join(pkgRoot, 'dist', 'electron.exe'))
82
- || existsSync(join(pkgRoot, 'dist', 'electron'))
83
- if (binaryReady) {
84
- const resolved: unknown = require('electron')
85
- if (typeof resolved === 'string' && resolved.length > 0) return resolved
86
- }
87
- }
88
- } catch {
89
- // continue probing
90
- }
91
- // 2. Explicit override (user intent wins).
92
- const override = process.env.ELECTRON_PATH
85
+ return resolveElectronPathImpl({
86
+ override: process.env.ELECTRON_PATH,
87
+ inElectron: typeof process.versions.electron === 'string',
88
+ execPath: process.execPath,
89
+ bundled: bundledElectronBinary,
90
+ anchored: anchoredElectronBinary,
91
+ ancestry: hostElectronViaProcessTree,
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Pure selection over the discovery probes, in order:
97
+ * 0. override (user intent) → 1. bundled package → 2. anchors (newest) →
98
+ * 3. in-process BARE host → 4. process ancestry → throw.
99
+ * Packaged app executables are never reused (steps 3/4 skip them).
100
+ */
101
+ function resolveElectronPathImpl(inputs: ElectronPathInputs): string {
102
+ // 0. Explicit override first: a user-set ELECTRON_PATH is a deliberate
103
+ // choice and must beat every auto-discovery (including the bundled
104
+ // package) — auto-discovery is a fallback, not an override.
105
+ const override = inputs.override
93
106
  if (typeof override === 'string' && override.length > 0 && existsSync(override)) return override
94
- // 3. Collect every candidate (anchors + pnpm stores), then pick the newest.
107
+ // 1. The plugin's own electron package (filesystem probe). Never require():
108
+ // inside an Electron main process require('electron') is the built-in
109
+ // API module, not the npm package, and electron 44+ downloads its binary
110
+ // on first require — probing must stay side-effect free (available()
111
+ // would otherwise block DSH startup on the download).
112
+ const bundled = inputs.bundled()
113
+ if (bundled !== undefined) return bundled
114
+ // 2. DSH install anchors, newest wins. (The plugin's own tree — plain and
115
+ // pnpm store layouts — was already scanned in step 1; the anchors cover
116
+ // electron installed separately, e.g. `dsh plugin add electron` into the
117
+ // profile or a global prefix.)
118
+ const anchored = inputs.anchored()
119
+ if (anchored !== undefined) return anchored
120
+ // 3. The current process IS a bare Electron (dev hosts, e.g. `electron .`):
121
+ // reuse the host binary — it cannot be missing because we are running on
122
+ // it. A PACKAGED app executable (resources/app.asar beside it, e.g. DSH
123
+ // Desktop.exe) is NOT spawnable as bare electron: it ignores the script
124
+ // argument, launches the app itself, and typically exits immediately
125
+ // (single-instance lock) — so it is never reused.
126
+ if (inputs.inElectron) {
127
+ const exe = inputs.execPath
128
+ if (typeof exe === 'string' && exe.length > 0 && existsSync(exe) && isBareElectron(exe)) return exe
129
+ }
130
+ // 4. Last resort for bare-Electron hosts: walk the process ancestry (the
131
+ // plugin may run in a child Node process of an Electron main process).
132
+ const hostExe = inputs.ancestry()
133
+ if (hostExe !== undefined) return hostExe
134
+ throw new Error(
135
+ 'dsh-builtin-browser: cannot locate a spawnable Electron binary. ' +
136
+ 'The electron package ships with the plugin (0.1.18+); Electron 44+ downloads its binary on first use, ' +
137
+ 'so if the package is installed but the binary is missing, run `npx install-electron` once (needs network) ' +
138
+ 'in the profile that installed the plugin, then retry. Alternatively set ELECTRON_PATH to an electron ' +
139
+ 'executable. On installs from before electron became a dependency, add it to the active profile ' +
140
+ '(`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the profile is `desktop`). ' +
141
+ 'Note: the host\'s own executable cannot be reused when it is a packaged Electron app (e.g. DSH Desktop.exe) ' +
142
+ '— only a real electron binary can be spawned.',
143
+ )
144
+ }
145
+
146
+ /** DSH install anchors: electron installed separately (profile / global prefix), newest wins. */
147
+ function anchoredElectronBinary(): string | undefined {
148
+ const require = createRequire(import.meta.url)
95
149
  const candidates: Array<{ version: string; path: string }> = []
96
150
  const add = (version: string, path: string | undefined): void => {
97
151
  if (path !== undefined) candidates.push({ version, path })
@@ -108,73 +162,95 @@ function resolveElectronPath(): string {
108
162
  for (const anchor of anchors) {
109
163
  try {
110
164
  const resolved: unknown = require.resolve('electron', { paths: [anchor] })
111
- if (typeof resolved === 'string' && resolved.length > 0) {
165
+ // Inside an Electron main process resolve('electron') yields the
166
+ // built-in module NAME (not a path) — requiring an absolute path keeps
167
+ // electronExeBeside from probing cwd-relative garbage like ./dist/.
168
+ if (typeof resolved === 'string' && resolved.length > 0 && isAbsolute(resolved)) {
112
169
  add(versionOf(resolved), electronExeBeside(resolved))
113
170
  }
114
171
  } catch {
115
172
  // continue probing
116
173
  }
117
174
  }
118
- // Scan ONLY the plugin's own install tree (plus the explicit DSH anchors
119
- // above). Never cwd or the executable's directory: those can sit inside an
120
- // unrelated project whose node_modules may hold a different electron, and
121
- // executing the newest random binary in the filesystem is a supply-chain
122
- // hazard with no benefit.
123
- const roots = new Set<string>([
124
- fileURLToPath(new URL('.', import.meta.url)),
125
- ])
126
- for (const root of roots) {
127
- let dir = root
128
- for (let depth = 0; depth < 8; depth++) {
129
- const store = join(dir, 'node_modules', '.pnpm')
130
- if (existsSync(store)) {
131
- for (const entry of readdirSync(store)) {
132
- if (!entry.startsWith('electron@')) continue
133
- const exe = electronDistExe(join(store, entry, 'node_modules', 'electron'))
134
- add(entry.slice('electron@'.length), exe)
135
- }
175
+ return pickNewest(candidates)
176
+ }
177
+
178
+ /**
179
+ * The plugin's own electron package binary, found by a pure filesystem walk
180
+ * (never require(), whose semantics differ inside an Electron main process —
181
+ * there `require('electron')` is the built-in API module, not the npm package
182
+ * — and which would trigger electron 44+'s lazy binary download when the dist
183
+ * is missing). Checks every `node_modules/electron` above this module,
184
+ * including pnpm virtual-store layouts, and returns the NEWEST ready binary.
185
+ * Scan ONLY the plugin's own install tree: never cwd or the executable's
186
+ * directory, which can sit inside an unrelated project whose node_modules may
187
+ * hold a different electron (executing the newest random binary in the
188
+ * filesystem is a supply-chain hazard with no benefit).
189
+ */
190
+ function bundledElectronBinary(): string | undefined {
191
+ const candidates: Array<{ version: string; path: string }> = []
192
+ const consider = (pkgRoot: string, versionHint: string): void => {
193
+ const exe = electronDistExe(pkgRoot)
194
+ if (exe !== undefined) candidates.push({ version: versionHint || pkgVersion(pkgRoot), path: exe })
195
+ }
196
+ let dir = fileURLToPath(new URL('.', import.meta.url))
197
+ for (let depth = 0; depth < 8; depth++) {
198
+ // Plain (npm/yarn, hoisted) layout: <ancestor>/node_modules/electron.
199
+ const plain = join(dir, 'node_modules', 'electron')
200
+ if (existsSync(join(plain, 'package.json'))) consider(plain, '')
201
+ // pnpm layout: <ancestor>/node_modules/.pnpm or <ancestor>/.pnpm, with
202
+ // electron@<version>/node_modules/electron entries.
203
+ for (const storeRoot of [join(dir, 'node_modules', '.pnpm'), join(dir, '.pnpm')]) {
204
+ if (!existsSync(storeRoot)) continue
205
+ for (const entry of readdirSync(storeRoot)) {
206
+ if (!entry.startsWith('electron@')) continue
207
+ const pkg = join(storeRoot, entry, 'node_modules', 'electron')
208
+ // Strip pnpm's peer-dependency suffix (electron@44.0.0_@types+node@22).
209
+ if (existsSync(join(pkg, 'package.json'))) consider(pkg, entry.slice('electron@'.length).split('_')[0])
136
210
  }
137
- const parent = join(dir, '..')
138
- if (parent === dir) break
139
- dir = parent
140
211
  }
212
+ const parent = join(dir, '..')
213
+ if (parent === dir) break
214
+ dir = parent
141
215
  }
142
- // 4. Last resort for Electron hosts. DSH Desktop may run the agent runtime
143
- // (and thus this plugin) in a plain Node child of its Electron main
144
- // process — step 0 does not fire there, and there is no electron package
145
- // anywhere to find. The host's own binary IS an Electron runtime and is
146
- // guaranteed present (it is running us), so walk the process ancestry
147
- // and reuse the first Electron executable found. Zero extra install;
148
- // this is what makes the plugin work on DSH Desktop without `add
149
- // electron`, no matter how the host loads plugins.
150
- const hostExe = hostElectronViaProcessTree()
151
- if (hostExe !== undefined) return hostExe
152
- throw new Error(
153
- 'dsh-builtin-browser: cannot locate the Electron binary. Options: (1) if you are on DSH Desktop, ' +
154
- 'this should never appear — the plugin reuses the host Electron automatically; (2) the electron ' +
155
- 'package ships with the plugin, so on newer installs nothing is needed here — on installs from ' +
156
- 'before that change, add it to the active profile (`dsh plugin --profile <your-profile> add electron`; ' +
157
- 'on DSH Desktop the profile is `desktop`); (3) set ELECTRON_PATH to the electron executable. Note: ' +
158
- 'Electron 44+ downloads its binary on first use, so if the package is installed but the binary is ' +
159
- 'missing, run `npx install-electron` once (needs network) and retry.',
160
- )
216
+ return pickNewest(candidates)
217
+ }
218
+
219
+ /** The electron version from a package root's package.json, or '0.0.0'. */
220
+ function pkgVersion(pkgRoot: string): string {
221
+ try {
222
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version?: unknown }
223
+ return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : '0.0.0'
224
+ } catch {
225
+ return '0.0.0'
226
+ }
227
+ }
228
+
229
+ /** The highest-versioned candidate's path, or undefined when none. */
230
+ function pickNewest(candidates: Array<{ version: string; path: string }>): string | undefined {
231
+ let best: { version: string; path: string } | undefined
232
+ for (const c of candidates) {
233
+ if (best === undefined || compareVersions(c.version, best.version) > 0) best = c
234
+ }
235
+ return best?.path
161
236
  }
162
237
 
163
238
  /**
164
239
  * Last-resort Electron discovery: walk this process's ancestry looking for a
165
- * parent process whose executable is itself an Electron binary. Covers hosts
166
- * that run the plugin in a child Node process of their own Electron main
167
- * process (e.g. DSH Desktop): the host binary is already on disk and running,
168
- * so reusing it needs no extra install. Filesystem-only on POSIX (/proc); on
169
- * Windows a best-effort PowerShell CIM query is used — this path only runs
170
- * when every other lookup failed, so its latency never touches the happy
171
- * path, and a missing/blocked powershell simply yields nothing.
240
+ * parent process whose executable is a BARE Electron binary (only bare
241
+ * electron can be spawned with a script argument). Covers hosts that run the
242
+ * plugin in a child Node process of their own Electron main process: the host
243
+ * binary is already on disk and running, so reusing it needs no extra
244
+ * install. Filesystem-only on POSIX (/proc); on Windows a best-effort
245
+ * PowerShell CIM query is used — this path only runs when every other lookup
246
+ * failed, so its latency never touches the happy path, and a
247
+ * missing/blocked powershell simply yields nothing.
172
248
  */
173
249
  function hostElectronViaProcessTree(): string | undefined {
174
250
  let pid: number | undefined = process.ppid
175
251
  for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
176
252
  const exe = processExecutable(pid)
177
- if (exe !== undefined && isElectronBinary(exe)) return exe
253
+ if (exe !== undefined && isBareElectron(exe)) return exe
178
254
  pid = processParent(pid)
179
255
  }
180
256
  return undefined
@@ -236,6 +312,32 @@ function isElectronBinary(exe: string): boolean {
236
312
  || existsSync(join(resources, 'default_app.asar'))
237
313
  }
238
314
 
315
+ /**
316
+ * True when the executable is a BARE (unpackaged) Electron that can be
317
+ * spawned with a script argument (`exe script.js --rpc-port N`). Packaged
318
+ * apps carry their code inside a resources dir (`app.asar`, or an unpacked
319
+ * `app` dir) and are NOT spawnable as bare electron: they ignore the script
320
+ * argument and launch the app itself, typically exiting immediately via the
321
+ * single-instance lock — spawning e.g. DSH Desktop.exe is exactly the
322
+ * "browser host exited (code=0)" failure. A portable single-file build (no
323
+ * resources dir at all) likewise cannot run a script argument.
324
+ */
325
+ function isBareElectron(exe: string): boolean {
326
+ const dir = dirname(exe)
327
+ // Windows/Linux keep resources beside the binary; macOS app bundles keep
328
+ // them in Contents/Resources, one level above the binary's MacOS dir.
329
+ const resourcesList = process.platform === 'darwin'
330
+ ? [join(dir, '..', 'Resources'), join(dir, 'resources')]
331
+ : [join(dir, 'resources')]
332
+ for (const resources of resourcesList) {
333
+ if (existsSync(join(resources, 'app.asar')) || existsSync(join(resources, 'app'))) return false
334
+ }
335
+ // A real electron always ships a resources dir somewhere; requiring one
336
+ // keeps every not-actually-spawnable layout out.
337
+ if (!resourcesList.some(p => existsSync(p))) return false
338
+ return isElectronBinary(exe)
339
+ }
340
+
239
341
  /** Extract an electron version like "43.4.0" from a path containing it. */
240
342
  function versionOf(path: string): string {
241
343
  const match = /electron@(\d+\.\d+\.\d+)/.exec(path)
@@ -281,6 +383,20 @@ interface Pending {
281
383
  reject(err: Error): void
282
384
  }
283
385
 
386
+ /**
387
+ * Thrown whenever the browser host (the Electron child) is gone — it exited,
388
+ * failed to start, or its RPC connection dropped. Operations against a dead
389
+ * host can never succeed, so the view layer treats this as "rebuild against a
390
+ * fresh host" instead of a page-level error. Kept module-private: callers
391
+ * observe the behavior (auto-restart), not the class.
392
+ */
393
+ class HostGoneError extends Error {
394
+ constructor(message: string) {
395
+ super(message)
396
+ this.name = 'HostGoneError'
397
+ }
398
+ }
399
+
284
400
  /**
285
401
  * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
286
402
  * process on Windows does not receive piped stdin, so the parent listens on a
@@ -311,8 +427,11 @@ class ElectronChildClient {
311
427
  private readonly token: string,
312
428
  private readonly onExit?: () => void,
313
429
  private readonly onUserAction?: (action: BrowserUserAction) => void,
430
+ /** Test seam: the executable to spawn instead of the resolved Electron
431
+ * binary. Absent -> resolveElectronPath() (production behavior). */
432
+ private readonly executable?: string,
314
433
  ) {
315
- const electron = resolveElectronPath()
434
+ const electron = this.executable ?? resolveElectronPath()
316
435
  process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
317
436
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
318
437
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
@@ -358,7 +477,10 @@ class ElectronChildClient {
358
477
  if (this.dead) return
359
478
  this.dead = true
360
479
  this.connected = false
361
- for (const pending of this.pending.values()) pending.reject(err)
480
+ // Tag every failure as "host gone" so the view layer can tell a dead host
481
+ // apart from a page-level error and rebuild against a fresh child.
482
+ const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
483
+ for (const pending of this.pending.values()) pending.reject(wrapped)
362
484
  this.pending.clear()
363
485
  this.outbox = []
364
486
  this.onExit?.()
@@ -469,14 +591,22 @@ class ElectronChildClient {
469
591
  /** Send one command and await the reply. */
470
592
  call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
471
593
  if (this.dead) {
472
- return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'))
594
+ return Promise.reject(new HostGoneError('dsh-builtin-browser: browser host is not running'))
473
595
  }
474
596
  const id = this.nextId++
475
597
  const line = JSON.stringify({ id, op, ...payload })
476
598
  return new Promise<T>((resolve, reject) => {
477
599
  this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
478
600
  if (this.connected && this.socket !== undefined) {
479
- this.socket.write(line + '\n')
601
+ try {
602
+ this.socket.write(line + '\n')
603
+ } catch {
604
+ // The socket died between the dead check and the write; the 'close'
605
+ // handler fails the rest. Reject THIS call as host-gone so the
606
+ // caller can recover immediately instead of hanging.
607
+ this.pending.delete(id)
608
+ reject(new HostGoneError('dsh-builtin-browser: browser host connection closed'))
609
+ }
480
610
  } else {
481
611
  // Not connected yet: queue; attach() flushes on the child's arrival.
482
612
  this.outbox.push(line)
@@ -547,7 +677,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
547
677
  /** The provider's user-action handler; routes toolbar actions into sessions. */
548
678
  private userActionHandler: ((action: BrowserUserAction) => void) | undefined
549
679
 
550
- constructor(private readonly hostMainPath: string) {}
680
+ constructor(
681
+ private readonly hostMainPath: string,
682
+ /** Test seam: the executable to spawn instead of the resolved Electron
683
+ * binary. Absent -> resolveElectronPath() (production behavior). */
684
+ private readonly spawnExecutable?: string,
685
+ ) {}
551
686
 
552
687
  /**
553
688
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
@@ -561,8 +696,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
561
696
  try {
562
697
  resolveElectronPath()
563
698
  this.electronAvailable = true
564
- } catch {
699
+ } catch (error) {
565
700
  this.electronAvailable = false
701
+ // The provider will be reported unavailable, so the detailed
702
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
703
+ // otherwise never reach the user — surface it on stderr.
704
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
566
705
  }
567
706
  }
568
707
  return this.electronAvailable
@@ -570,6 +709,10 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
570
709
 
571
710
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
572
711
  private ready(): Promise<void> {
712
+ // Fail fast on a disposed host: a recovery path can re-enter ready() after
713
+ // dispose() cleared its state, and starting fresh here would spawn a child
714
+ // nobody will ever kill.
715
+ if (this.disposed) throw new HostGoneError('browser host disposed')
573
716
  if (this.readyPromise !== undefined) return this.readyPromise
574
717
  const started = this.start()
575
718
  const wrapped = started.catch(error => {
@@ -581,6 +724,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
581
724
  this.client = undefined
582
725
  this.server?.close()
583
726
  this.server = undefined
727
+ this.pendingSocket?.destroy()
584
728
  this.pendingSocket = undefined
585
729
  }
586
730
  throw error
@@ -631,11 +775,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
631
775
  token,
632
776
  () => this.onChildExit(),
633
777
  action => this.userActionHandler?.(action),
778
+ this.spawnExecutable,
634
779
  )
635
780
  if (this.pendingSocket !== undefined) {
636
781
  this.client.attach(this.pendingSocket)
637
782
  this.pendingSocket = undefined
638
783
  }
784
+ // dispose() can race this spawn (it may run while start() is suspended
785
+ // between the listen and the client creation): re-check here so a host
786
+ // that was torn down mid-start does not leak a child nobody will kill.
787
+ if (this.disposed) {
788
+ this.client.kill()
789
+ this.client = undefined
790
+ try { server.close() } catch { /* already closed */ }
791
+ if (this.server === server) this.server = undefined
792
+ throw new HostGoneError('browser host disposed')
793
+ }
639
794
  // Wait for the child's connection + authentication + readiness ping. The
640
795
  // ping is queued until the hello authenticates, so a spoofed socket
641
796
  // never observes it.
@@ -645,13 +800,17 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
645
800
  /** The child died: tear down so the next use starts a fresh child. */
646
801
  private onChildExit(): void {
647
802
  if (this.disposed) return
803
+ process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
648
804
  this.client = undefined
649
805
  this.server?.close()
650
806
  this.server = undefined
807
+ this.pendingSocket?.destroy()
651
808
  this.pendingSocket = undefined
652
809
  this.readyPromise = undefined
653
- // Keep the views map: handles still resolve to ids; a fresh child simply
654
- // has no such views yet, and reset_session reopens clean sessions.
810
+ // Keep the views map: handles still resolve to ids, and
811
+ // DeferredRemoteView re-materializes them against the fresh child on
812
+ // their next use — sessions opened before the crash stay usable instead
813
+ // of going stale (only the page state is lost).
655
814
  }
656
815
 
657
816
  createView(): ElectronViewHandle {
@@ -664,9 +823,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
664
823
  }
665
824
 
666
825
  private async ensureView(id: string): Promise<RemoteView> {
826
+ // A rebuild in flight during dispose() must not spawn a fresh child that
827
+ // would then outlive the host (a zombie window nobody disposes).
828
+ if (this.disposed) throw new HostGoneError('browser host disposed')
667
829
  await this.ready()
830
+ // dispose() may have landed while the host was starting (ready() resolved
831
+ // after dispose cleared its state): never materialize into a dead host.
832
+ if (this.disposed) throw new HostGoneError('browser host disposed')
668
833
  const client = this.client
669
- if (client === undefined) throw new Error('browser host unavailable')
834
+ if (client === undefined) throw new HostGoneError('browser host unavailable')
670
835
  // Re-send the window group before creating the view: the child routes the
671
836
  // view into its session's own window. Re-sending every materialization
672
837
  // also covers a child restart (the new child has no assignments yet).
@@ -733,6 +898,8 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
733
898
  this.client = undefined
734
899
  this.server?.close()
735
900
  this.server = undefined
901
+ this.pendingSocket?.destroy()
902
+ this.pendingSocket = undefined
736
903
  this.readyPromise = undefined
737
904
  this.views.clear()
738
905
  }
@@ -748,7 +915,7 @@ class DeferredRemoteView implements ElectronViewHandle {
748
915
  ) {}
749
916
 
750
917
  /**
751
- * Materialize once and cache: every sendCommand on the same handle must
918
+ * Materialize once and cache: every operation on the same handle must
752
919
  * target the SAME child view (re-materializing would re-run createView and
753
920
  * duplicate the window). A FAILED materialization is reset so a later call
754
921
  * (e.g. after the host restarted) can retry instead of being poisoned.
@@ -764,29 +931,51 @@ class DeferredRemoteView implements ElectronViewHandle {
764
931
  return this.materialized
765
932
  }
766
933
 
934
+ /**
935
+ * Run one operation against this view's backing child, automatically
936
+ * recovering from a dead host. The cached materialization is bound to the
937
+ * child it was created on; once that child is gone (exited, crashed, or its
938
+ * connection dropped — e.g. the DSH process that spawned it was restarted),
939
+ * any further operation on the same handle would otherwise fail forever
940
+ * with "browser host is not running". Instead: drop the cache, rebuild the
941
+ * view against a freshly spawned child, and retry the operation exactly
942
+ * once — so sessions that survive a host death self-heal on their FIRST
943
+ * call after it, without a manual browser_reset_session. Page-level errors
944
+ * are never retried (the view itself is fine).
945
+ */
946
+ private async withRecovery<T>(op: (view: RemoteView) => Promise<T>): Promise<T> {
947
+ try {
948
+ const view = await this.materializeOnce()
949
+ return await op(view)
950
+ } catch (error) {
951
+ if (!(error instanceof HostGoneError)) throw error
952
+ // The backing child is gone: a retry on the same materialization can
953
+ // never succeed. Rebuild against a fresh child and retry once; a second
954
+ // failure (fresh host also failing to start) surfaces to the caller.
955
+ this.materialized = undefined
956
+ const fresh = await this.materializeOnce()
957
+ return op(fresh)
958
+ }
959
+ }
960
+
767
961
  async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
768
- const view = await this.materializeOnce()
769
- return view.sendCommand(method, params)
962
+ return this.withRecovery(view => view.sendCommand(method, params))
770
963
  }
771
964
 
772
965
  async download(url: string, savePath: string): Promise<void> {
773
- const view = await this.materializeOnce()
774
- return view.download(url, savePath)
966
+ return this.withRecovery(view => view.download(url, savePath))
775
967
  }
776
968
 
777
969
  async capture(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string }> {
778
- const view = await this.materializeOnce()
779
- return view.capture(opts)
970
+ return this.withRecovery(view => view.capture(opts))
780
971
  }
781
972
 
782
973
  async flushAuth(): Promise<ExportedCookie[]> {
783
- const view = await this.materializeOnce()
784
- return view.flushAuth()
974
+ return this.withRecovery(view => view.flushAuth())
785
975
  }
786
976
 
787
977
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
788
- const view = await this.materializeOnce()
789
- return view.restoreAuth(cookies)
978
+ return this.withRecovery(view => view.restoreAuth(cookies))
790
979
  }
791
980
  }
792
981
 
@@ -805,3 +994,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promi
805
994
  export function defaultHostMainPath(): string {
806
995
  return fileURLToPath(new URL('./host-main.js', import.meta.url))
807
996
  }
997
+
998
+ /**
999
+ * Test/diagnostic hooks (mirrors tool-browser's `internals` convention).
1000
+ * `isBareElectron` is the packaged-app discriminator behind the host-reuse
1001
+ * steps; `resolveElectronPath` is the full resolution order.
1002
+ */
1003
+ export const internals = {
1004
+ isBareElectron,
1005
+ resolveElectronPath,
1006
+ resolveElectronPathImpl,
1007
+ }