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.
@@ -28,67 +28,79 @@ import { execFileSync, spawn } from 'node:child_process';
28
28
  import { randomBytes } from 'node:crypto';
29
29
  import { createRequire } from 'node:module';
30
30
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
31
- import { basename, dirname, join } from 'node:path';
31
+ import { basename, dirname, isAbsolute, join } from 'node:path';
32
32
  import { createServer } from 'node:net';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  /** How long to wait for the child to signal readiness before failing. */
35
35
  const READY_TIMEOUT_MS = 20_000;
36
36
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
37
37
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
38
+ function resolveElectronPath() {
39
+ return resolveElectronPathImpl({
40
+ override: process.env.ELECTRON_PATH,
41
+ inElectron: typeof process.versions.electron === 'string',
42
+ execPath: process.execPath,
43
+ bundled: bundledElectronBinary,
44
+ anchored: anchoredElectronBinary,
45
+ ancestry: hostElectronViaProcessTree,
46
+ });
47
+ }
38
48
  /**
39
- * Locate the Electron binary:
40
- * 0. the current process IS Electron (DSH Desktop hosts load this plugin
41
- * inside their own Electron main process) — reuse the host binary, so
42
- * the plugin works out of the box without `add electron`;
43
- * 1. require('electron') from this module (bundled dependency — the
44
- * electron package ships with the plugin),
45
- * 2. ELECTRON_PATH (explicit override),
46
- * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
47
- * NEWEST version found. Older Electron releases (e.g. 33.x) have a
48
- * compositor defect that intermittently breaks page capture, so prefer
49
- * the newest binary available in the environment,
50
- * 4. last resort: the process ancestry, for hosts that run this plugin in a
51
- * child Node process of their own Electron main process (DSH Desktop).
52
- * @returns the path to the Electron executable.
49
+ * Pure selection over the discovery probes, in order:
50
+ * 0. override (user intent) → 1. bundled package → 2. anchors (newest) →
51
+ * 3. in-process BARE host → 4. process ancestry → throw.
52
+ * Packaged app executables are never reused (steps 3/4 skip them).
53
53
  */
54
- function resolveElectronPath() {
55
- const require = createRequire(import.meta.url);
56
- // 0. The current process IS Electron: reuse the host binary. It cannot be
57
- // missing because we are running on it.
58
- if (typeof process.versions.electron === 'string') {
59
- const exe = process.execPath;
60
- if (typeof exe === 'string' && exe.length > 0 && existsSync(exe))
61
- return exe;
62
- }
63
- // 1. Bundled dependency — but ONLY when its binary is already on disk.
64
- // Electron 44+ downloads the binary lazily on the first require()
65
- // (it has no postinstall anymore), so probing here must never trigger a
66
- // synchronous network download: available() would block DSH startup for
67
- // the download (or fail outright offline), surfacing as a broken plugin.
68
- // The download still happens on the first real spawn; this guard only
69
- // keeps the probe side-effect free.
70
- try {
71
- const entry = require.resolve('electron');
72
- if (typeof entry === 'string' && entry.length > 0) {
73
- const pkgRoot = dirname(entry);
74
- const binaryReady = existsSync(join(pkgRoot, 'path.txt'))
75
- || existsSync(join(pkgRoot, 'dist', 'electron.exe'))
76
- || existsSync(join(pkgRoot, 'dist', 'electron'));
77
- if (binaryReady) {
78
- const resolved = require('electron');
79
- if (typeof resolved === 'string' && resolved.length > 0)
80
- return resolved;
81
- }
82
- }
83
- }
84
- catch {
85
- // continue probing
86
- }
87
- // 2. Explicit override (user intent wins).
88
- const override = process.env.ELECTRON_PATH;
54
+ function resolveElectronPathImpl(inputs) {
55
+ // 0. Explicit override first: a user-set ELECTRON_PATH is a deliberate
56
+ // choice and must beat every auto-discovery (including the bundled
57
+ // package) — auto-discovery is a fallback, not an override.
58
+ const override = inputs.override;
89
59
  if (typeof override === 'string' && override.length > 0 && existsSync(override))
90
60
  return override;
91
- // 3. Collect every candidate (anchors + pnpm stores), then pick the newest.
61
+ // 1. The plugin's own electron package (filesystem probe). Never require():
62
+ // inside an Electron main process require('electron') is the built-in
63
+ // API module, not the npm package, and electron 44+ downloads its binary
64
+ // on first require — probing must stay side-effect free (available()
65
+ // would otherwise block DSH startup on the download).
66
+ const bundled = inputs.bundled();
67
+ if (bundled !== undefined)
68
+ return bundled;
69
+ // 2. DSH install anchors, newest wins. (The plugin's own tree — plain and
70
+ // pnpm store layouts — was already scanned in step 1; the anchors cover
71
+ // electron installed separately, e.g. `dsh plugin add electron` into the
72
+ // profile or a global prefix.)
73
+ const anchored = inputs.anchored();
74
+ if (anchored !== undefined)
75
+ return anchored;
76
+ // 3. The current process IS a bare Electron (dev hosts, e.g. `electron .`):
77
+ // reuse the host binary — it cannot be missing because we are running on
78
+ // it. A PACKAGED app executable (resources/app.asar beside it, e.g. DSH
79
+ // Desktop.exe) is NOT spawnable as bare electron: it ignores the script
80
+ // argument, launches the app itself, and typically exits immediately
81
+ // (single-instance lock) — so it is never reused.
82
+ if (inputs.inElectron) {
83
+ const exe = inputs.execPath;
84
+ if (typeof exe === 'string' && exe.length > 0 && existsSync(exe) && isBareElectron(exe))
85
+ return exe;
86
+ }
87
+ // 4. Last resort for bare-Electron hosts: walk the process ancestry (the
88
+ // plugin may run in a child Node process of an Electron main process).
89
+ const hostExe = inputs.ancestry();
90
+ if (hostExe !== undefined)
91
+ return hostExe;
92
+ throw new Error('dsh-builtin-browser: cannot locate a spawnable Electron binary. ' +
93
+ 'The electron package ships with the plugin (0.1.18+); Electron 44+ downloads its binary on first use, ' +
94
+ 'so if the package is installed but the binary is missing, run `npx install-electron` once (needs network) ' +
95
+ 'in the profile that installed the plugin, then retry. Alternatively set ELECTRON_PATH to an electron ' +
96
+ 'executable. On installs from before electron became a dependency, add it to the active profile ' +
97
+ '(`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the profile is `desktop`). ' +
98
+ 'Note: the host\'s own executable cannot be reused when it is a packaged Electron app (e.g. DSH Desktop.exe) ' +
99
+ '— only a real electron binary can be spawned.');
100
+ }
101
+ /** DSH install anchors: electron installed separately (profile / global prefix), newest wins. */
102
+ function anchoredElectronBinary() {
103
+ const require = createRequire(import.meta.url);
92
104
  const candidates = [];
93
105
  const add = (version, path) => {
94
106
  if (path !== undefined)
@@ -106,7 +118,10 @@ function resolveElectronPath() {
106
118
  for (const anchor of anchors) {
107
119
  try {
108
120
  const resolved = require.resolve('electron', { paths: [anchor] });
109
- if (typeof resolved === 'string' && resolved.length > 0) {
121
+ // Inside an Electron main process resolve('electron') yields the
122
+ // built-in module NAME (not a path) — requiring an absolute path keeps
123
+ // electronExeBeside from probing cwd-relative garbage like ./dist/.
124
+ if (typeof resolved === 'string' && resolved.length > 0 && isAbsolute(resolved)) {
110
125
  add(versionOf(resolved), electronExeBeside(resolved));
111
126
  }
112
127
  }
@@ -114,66 +129,89 @@ function resolveElectronPath() {
114
129
  // continue probing
115
130
  }
116
131
  }
117
- // Scan ONLY the plugin's own install tree (plus the explicit DSH anchors
118
- // above). Never cwd or the executable's directory: those can sit inside an
119
- // unrelated project whose node_modules may hold a different electron, and
120
- // executing the newest random binary in the filesystem is a supply-chain
121
- // hazard with no benefit.
122
- const roots = new Set([
123
- fileURLToPath(new URL('.', import.meta.url)),
124
- ]);
125
- for (const root of roots) {
126
- let dir = root;
127
- for (let depth = 0; depth < 8; depth++) {
128
- const store = join(dir, 'node_modules', '.pnpm');
129
- if (existsSync(store)) {
130
- for (const entry of readdirSync(store)) {
131
- if (!entry.startsWith('electron@'))
132
- continue;
133
- const exe = electronDistExe(join(store, entry, 'node_modules', 'electron'));
134
- add(entry.slice('electron@'.length), exe);
135
- }
132
+ return pickNewest(candidates);
133
+ }
134
+ /**
135
+ * The plugin's own electron package binary, found by a pure filesystem walk
136
+ * (never require(), whose semantics differ inside an Electron main process —
137
+ * there `require('electron')` is the built-in API module, not the npm package
138
+ * — and which would trigger electron 44+'s lazy binary download when the dist
139
+ * is missing). Checks every `node_modules/electron` above this module,
140
+ * including pnpm virtual-store layouts, and returns the NEWEST ready binary.
141
+ * Scan ONLY the plugin's own install tree: never cwd or the executable's
142
+ * directory, which can sit inside an unrelated project whose node_modules may
143
+ * hold a different electron (executing the newest random binary in the
144
+ * filesystem is a supply-chain hazard with no benefit).
145
+ */
146
+ function bundledElectronBinary() {
147
+ const candidates = [];
148
+ const consider = (pkgRoot, versionHint) => {
149
+ const exe = electronDistExe(pkgRoot);
150
+ if (exe !== undefined)
151
+ candidates.push({ version: versionHint || pkgVersion(pkgRoot), path: exe });
152
+ };
153
+ let dir = fileURLToPath(new URL('.', import.meta.url));
154
+ for (let depth = 0; depth < 8; depth++) {
155
+ // Plain (npm/yarn, hoisted) layout: <ancestor>/node_modules/electron.
156
+ const plain = join(dir, 'node_modules', 'electron');
157
+ if (existsSync(join(plain, 'package.json')))
158
+ consider(plain, '');
159
+ // pnpm layout: <ancestor>/node_modules/.pnpm or <ancestor>/.pnpm, with
160
+ // electron@<version>/node_modules/electron entries.
161
+ for (const storeRoot of [join(dir, 'node_modules', '.pnpm'), join(dir, '.pnpm')]) {
162
+ if (!existsSync(storeRoot))
163
+ continue;
164
+ for (const entry of readdirSync(storeRoot)) {
165
+ if (!entry.startsWith('electron@'))
166
+ continue;
167
+ const pkg = join(storeRoot, entry, 'node_modules', 'electron');
168
+ // Strip pnpm's peer-dependency suffix (electron@44.0.0_@types+node@22).
169
+ if (existsSync(join(pkg, 'package.json')))
170
+ consider(pkg, entry.slice('electron@'.length).split('_')[0]);
136
171
  }
137
- const parent = join(dir, '..');
138
- if (parent === dir)
139
- break;
140
- dir = parent;
141
172
  }
173
+ const parent = join(dir, '..');
174
+ if (parent === dir)
175
+ break;
176
+ dir = parent;
142
177
  }
143
- // 4. Last resort for Electron hosts. DSH Desktop may run the agent runtime
144
- // (and thus this plugin) in a plain Node child of its Electron main
145
- // process — step 0 does not fire there, and there is no electron package
146
- // anywhere to find. The host's own binary IS an Electron runtime and is
147
- // guaranteed present (it is running us), so walk the process ancestry
148
- // and reuse the first Electron executable found. Zero extra install;
149
- // this is what makes the plugin work on DSH Desktop without `add
150
- // electron`, no matter how the host loads plugins.
151
- const hostExe = hostElectronViaProcessTree();
152
- if (hostExe !== undefined)
153
- return hostExe;
154
- throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Options: (1) if you are on DSH Desktop, ' +
155
- 'this should never appear — the plugin reuses the host Electron automatically; (2) the electron ' +
156
- 'package ships with the plugin, so on newer installs nothing is needed here — on installs from ' +
157
- 'before that change, add it to the active profile (`dsh plugin --profile <your-profile> add electron`; ' +
158
- 'on DSH Desktop the profile is `desktop`); (3) set ELECTRON_PATH to the electron executable. Note: ' +
159
- 'Electron 44+ downloads its binary on first use, so if the package is installed but the binary is ' +
160
- 'missing, run `npx install-electron` once (needs network) and retry.');
178
+ return pickNewest(candidates);
179
+ }
180
+ /** The electron version from a package root's package.json, or '0.0.0'. */
181
+ function pkgVersion(pkgRoot) {
182
+ try {
183
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
184
+ return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : '0.0.0';
185
+ }
186
+ catch {
187
+ return '0.0.0';
188
+ }
189
+ }
190
+ /** The highest-versioned candidate's path, or undefined when none. */
191
+ function pickNewest(candidates) {
192
+ let best;
193
+ for (const c of candidates) {
194
+ if (best === undefined || compareVersions(c.version, best.version) > 0)
195
+ best = c;
196
+ }
197
+ return best?.path;
161
198
  }
162
199
  /**
163
200
  * Last-resort Electron discovery: walk this process's ancestry looking for a
164
- * parent process whose executable is itself an Electron binary. Covers hosts
165
- * that run the plugin in a child Node process of their own Electron main
166
- * process (e.g. DSH Desktop): the host binary is already on disk and running,
167
- * so reusing it needs no extra install. Filesystem-only on POSIX (/proc); on
168
- * Windows a best-effort PowerShell CIM query is used — this path only runs
169
- * when every other lookup failed, so its latency never touches the happy
170
- * path, and a missing/blocked powershell simply yields nothing.
201
+ * parent process whose executable is a BARE Electron binary (only bare
202
+ * electron can be spawned with a script argument). Covers hosts that run the
203
+ * plugin in a child Node process of their own Electron main process: the host
204
+ * binary is already on disk and running, so reusing it needs no extra
205
+ * install. Filesystem-only on POSIX (/proc); on Windows a best-effort
206
+ * PowerShell CIM query is used — this path only runs when every other lookup
207
+ * failed, so its latency never touches the happy path, and a
208
+ * missing/blocked powershell simply yields nothing.
171
209
  */
172
210
  function hostElectronViaProcessTree() {
173
211
  let pid = process.ppid;
174
212
  for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
175
213
  const exe = processExecutable(pid);
176
- if (exe !== undefined && isElectronBinary(exe))
214
+ if (exe !== undefined && isBareElectron(exe))
177
215
  return exe;
178
216
  pid = processParent(pid);
179
217
  }
@@ -235,6 +273,33 @@ function isElectronBinary(exe) {
235
273
  || existsSync(join(resources, 'app.asar'))
236
274
  || existsSync(join(resources, 'default_app.asar'));
237
275
  }
276
+ /**
277
+ * True when the executable is a BARE (unpackaged) Electron that can be
278
+ * spawned with a script argument (`exe script.js --rpc-port N`). Packaged
279
+ * apps carry their code inside a resources dir (`app.asar`, or an unpacked
280
+ * `app` dir) and are NOT spawnable as bare electron: they ignore the script
281
+ * argument and launch the app itself, typically exiting immediately via the
282
+ * single-instance lock — spawning e.g. DSH Desktop.exe is exactly the
283
+ * "browser host exited (code=0)" failure. A portable single-file build (no
284
+ * resources dir at all) likewise cannot run a script argument.
285
+ */
286
+ function isBareElectron(exe) {
287
+ const dir = dirname(exe);
288
+ // Windows/Linux keep resources beside the binary; macOS app bundles keep
289
+ // them in Contents/Resources, one level above the binary's MacOS dir.
290
+ const resourcesList = process.platform === 'darwin'
291
+ ? [join(dir, '..', 'Resources'), join(dir, 'resources')]
292
+ : [join(dir, 'resources')];
293
+ for (const resources of resourcesList) {
294
+ if (existsSync(join(resources, 'app.asar')) || existsSync(join(resources, 'app')))
295
+ return false;
296
+ }
297
+ // A real electron always ships a resources dir somewhere; requiring one
298
+ // keeps every not-actually-spawnable layout out.
299
+ if (!resourcesList.some(p => existsSync(p)))
300
+ return false;
301
+ return isElectronBinary(exe);
302
+ }
238
303
  /** Extract an electron version like "43.4.0" from a path containing it. */
239
304
  function versionOf(path) {
240
305
  const match = /electron@(\d+\.\d+\.\d+)/.exec(path);
@@ -273,6 +338,19 @@ function electronDistExe(pkgRoot) {
273
338
  }
274
339
  return undefined;
275
340
  }
341
+ /**
342
+ * Thrown whenever the browser host (the Electron child) is gone — it exited,
343
+ * failed to start, or its RPC connection dropped. Operations against a dead
344
+ * host can never succeed, so the view layer treats this as "rebuild against a
345
+ * fresh host" instead of a page-level error. Kept module-private: callers
346
+ * observe the behavior (auto-restart), not the class.
347
+ */
348
+ class HostGoneError extends Error {
349
+ constructor(message) {
350
+ super(message);
351
+ this.name = 'HostGoneError';
352
+ }
353
+ }
276
354
  /**
277
355
  * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
278
356
  * process on Windows does not receive piped stdin, so the parent listens on a
@@ -285,6 +363,7 @@ class ElectronChildClient {
285
363
  token;
286
364
  onExit;
287
365
  onUserAction;
366
+ executable;
288
367
  child;
289
368
  pending = new Map();
290
369
  nextId = 1;
@@ -301,13 +380,17 @@ class ElectronChildClient {
301
380
  */
302
381
  awaitingHello = true;
303
382
  helloTimer;
304
- constructor(hostMainPath, port, token, onExit, onUserAction) {
383
+ constructor(hostMainPath, port, token, onExit, onUserAction,
384
+ /** Test seam: the executable to spawn instead of the resolved Electron
385
+ * binary. Absent -> resolveElectronPath() (production behavior). */
386
+ executable) {
305
387
  this.hostMainPath = hostMainPath;
306
388
  this.port = port;
307
389
  this.token = token;
308
390
  this.onExit = onExit;
309
391
  this.onUserAction = onUserAction;
310
- const electron = resolveElectronPath();
392
+ this.executable = executable;
393
+ const electron = this.executable ?? resolveElectronPath();
311
394
  process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
312
395
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
313
396
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
@@ -353,8 +436,11 @@ class ElectronChildClient {
353
436
  return;
354
437
  this.dead = true;
355
438
  this.connected = false;
439
+ // Tag every failure as "host gone" so the view layer can tell a dead host
440
+ // apart from a page-level error and rebuild against a fresh child.
441
+ const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message);
356
442
  for (const pending of this.pending.values())
357
- pending.reject(err);
443
+ pending.reject(wrapped);
358
444
  this.pending.clear();
359
445
  this.outbox = [];
360
446
  this.onExit?.();
@@ -468,14 +554,23 @@ class ElectronChildClient {
468
554
  /** Send one command and await the reply. */
469
555
  call(op, payload = {}) {
470
556
  if (this.dead) {
471
- return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'));
557
+ return Promise.reject(new HostGoneError('dsh-builtin-browser: browser host is not running'));
472
558
  }
473
559
  const id = this.nextId++;
474
560
  const line = JSON.stringify({ id, op, ...payload });
475
561
  return new Promise((resolve, reject) => {
476
562
  this.pending.set(id, { resolve: resolve, reject });
477
563
  if (this.connected && this.socket !== undefined) {
478
- this.socket.write(line + '\n');
564
+ try {
565
+ this.socket.write(line + '\n');
566
+ }
567
+ catch {
568
+ // The socket died between the dead check and the write; the 'close'
569
+ // handler fails the rest. Reject THIS call as host-gone so the
570
+ // caller can recover immediately instead of hanging.
571
+ this.pending.delete(id);
572
+ reject(new HostGoneError('dsh-builtin-browser: browser host connection closed'));
573
+ }
479
574
  }
480
575
  else {
481
576
  // Not connected yet: queue; attach() flushes on the child's arrival.
@@ -534,6 +629,7 @@ class RemoteView {
534
629
  */
535
630
  export class RemoteElectronViewHost {
536
631
  hostMainPath;
632
+ spawnExecutable;
537
633
  client;
538
634
  server;
539
635
  pendingSocket;
@@ -547,8 +643,12 @@ export class RemoteElectronViewHost {
547
643
  groups = new Map();
548
644
  /** The provider's user-action handler; routes toolbar actions into sessions. */
549
645
  userActionHandler;
550
- constructor(hostMainPath) {
646
+ constructor(hostMainPath,
647
+ /** Test seam: the executable to spawn instead of the resolved Electron
648
+ * binary. Absent -> resolveElectronPath() (production behavior). */
649
+ spawnExecutable) {
551
650
  this.hostMainPath = hostMainPath;
651
+ this.spawnExecutable = spawnExecutable;
552
652
  }
553
653
  /**
554
654
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
@@ -563,14 +663,23 @@ export class RemoteElectronViewHost {
563
663
  resolveElectronPath();
564
664
  this.electronAvailable = true;
565
665
  }
566
- catch {
666
+ catch (error) {
567
667
  this.electronAvailable = false;
668
+ // The provider will be reported unavailable, so the detailed
669
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
670
+ // otherwise never reach the user — surface it on stderr.
671
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
568
672
  }
569
673
  }
570
674
  return this.electronAvailable;
571
675
  }
572
676
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
573
677
  ready() {
678
+ // Fail fast on a disposed host: a recovery path can re-enter ready() after
679
+ // dispose() cleared its state, and starting fresh here would spawn a child
680
+ // nobody will ever kill.
681
+ if (this.disposed)
682
+ throw new HostGoneError('browser host disposed');
574
683
  if (this.readyPromise !== undefined)
575
684
  return this.readyPromise;
576
685
  const started = this.start();
@@ -583,6 +692,7 @@ export class RemoteElectronViewHost {
583
692
  this.client = undefined;
584
693
  this.server?.close();
585
694
  this.server = undefined;
695
+ this.pendingSocket?.destroy();
586
696
  this.pendingSocket = undefined;
587
697
  }
588
698
  throw error;
@@ -626,11 +736,25 @@ export class RemoteElectronViewHost {
626
736
  // Random per-spawn token: the child must prove knowledge of it (via
627
737
  // stdin hello) before any command is written to the socket.
628
738
  const token = randomBytes(24).toString('hex');
629
- this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action));
739
+ this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), this.spawnExecutable);
630
740
  if (this.pendingSocket !== undefined) {
631
741
  this.client.attach(this.pendingSocket);
632
742
  this.pendingSocket = undefined;
633
743
  }
744
+ // dispose() can race this spawn (it may run while start() is suspended
745
+ // between the listen and the client creation): re-check here so a host
746
+ // that was torn down mid-start does not leak a child nobody will kill.
747
+ if (this.disposed) {
748
+ this.client.kill();
749
+ this.client = undefined;
750
+ try {
751
+ server.close();
752
+ }
753
+ catch { /* already closed */ }
754
+ if (this.server === server)
755
+ this.server = undefined;
756
+ throw new HostGoneError('browser host disposed');
757
+ }
634
758
  // Wait for the child's connection + authentication + readiness ping. The
635
759
  // ping is queued until the hello authenticates, so a spoofed socket
636
760
  // never observes it.
@@ -640,13 +764,17 @@ export class RemoteElectronViewHost {
640
764
  onChildExit() {
641
765
  if (this.disposed)
642
766
  return;
767
+ process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
643
768
  this.client = undefined;
644
769
  this.server?.close();
645
770
  this.server = undefined;
771
+ this.pendingSocket?.destroy();
646
772
  this.pendingSocket = undefined;
647
773
  this.readyPromise = undefined;
648
- // Keep the views map: handles still resolve to ids; a fresh child simply
649
- // has no such views yet, and reset_session reopens clean sessions.
774
+ // Keep the views map: handles still resolve to ids, and
775
+ // DeferredRemoteView re-materializes them against the fresh child on
776
+ // their next use — sessions opened before the crash stay usable instead
777
+ // of going stale (only the page state is lost).
650
778
  }
651
779
  createView() {
652
780
  // The seam is synchronous; the provider uses the handle immediately, so
@@ -657,10 +785,18 @@ export class RemoteElectronViewHost {
657
785
  return view;
658
786
  }
659
787
  async ensureView(id) {
788
+ // A rebuild in flight during dispose() must not spawn a fresh child that
789
+ // would then outlive the host (a zombie window nobody disposes).
790
+ if (this.disposed)
791
+ throw new HostGoneError('browser host disposed');
660
792
  await this.ready();
793
+ // dispose() may have landed while the host was starting (ready() resolved
794
+ // after dispose cleared its state): never materialize into a dead host.
795
+ if (this.disposed)
796
+ throw new HostGoneError('browser host disposed');
661
797
  const client = this.client;
662
798
  if (client === undefined)
663
- throw new Error('browser host unavailable');
799
+ throw new HostGoneError('browser host unavailable');
664
800
  // Re-send the window group before creating the view: the child routes the
665
801
  // view into its session's own window. Re-sending every materialization
666
802
  // also covers a child restart (the new child has no assignments yet).
@@ -722,6 +858,8 @@ export class RemoteElectronViewHost {
722
858
  this.client = undefined;
723
859
  this.server?.close();
724
860
  this.server = undefined;
861
+ this.pendingSocket?.destroy();
862
+ this.pendingSocket = undefined;
725
863
  this.readyPromise = undefined;
726
864
  this.views.clear();
727
865
  }
@@ -736,7 +874,7 @@ class DeferredRemoteView {
736
874
  this.materialize = materialize;
737
875
  }
738
876
  /**
739
- * Materialize once and cache: every sendCommand on the same handle must
877
+ * Materialize once and cache: every operation on the same handle must
740
878
  * target the SAME child view (re-materializing would re-run createView and
741
879
  * duplicate the window). A FAILED materialization is reset so a later call
742
880
  * (e.g. after the host restarted) can retry instead of being poisoned.
@@ -752,25 +890,48 @@ class DeferredRemoteView {
752
890
  }
753
891
  return this.materialized;
754
892
  }
893
+ /**
894
+ * Run one operation against this view's backing child, automatically
895
+ * recovering from a dead host. The cached materialization is bound to the
896
+ * child it was created on; once that child is gone (exited, crashed, or its
897
+ * connection dropped — e.g. the DSH process that spawned it was restarted),
898
+ * any further operation on the same handle would otherwise fail forever
899
+ * with "browser host is not running". Instead: drop the cache, rebuild the
900
+ * view against a freshly spawned child, and retry the operation exactly
901
+ * once — so sessions that survive a host death self-heal on their FIRST
902
+ * call after it, without a manual browser_reset_session. Page-level errors
903
+ * are never retried (the view itself is fine).
904
+ */
905
+ async withRecovery(op) {
906
+ try {
907
+ const view = await this.materializeOnce();
908
+ return await op(view);
909
+ }
910
+ catch (error) {
911
+ if (!(error instanceof HostGoneError))
912
+ throw error;
913
+ // The backing child is gone: a retry on the same materialization can
914
+ // never succeed. Rebuild against a fresh child and retry once; a second
915
+ // failure (fresh host also failing to start) surfaces to the caller.
916
+ this.materialized = undefined;
917
+ const fresh = await this.materializeOnce();
918
+ return op(fresh);
919
+ }
920
+ }
755
921
  async sendCommand(method, params) {
756
- const view = await this.materializeOnce();
757
- return view.sendCommand(method, params);
922
+ return this.withRecovery(view => view.sendCommand(method, params));
758
923
  }
759
924
  async download(url, savePath) {
760
- const view = await this.materializeOnce();
761
- return view.download(url, savePath);
925
+ return this.withRecovery(view => view.download(url, savePath));
762
926
  }
763
927
  async capture(opts) {
764
- const view = await this.materializeOnce();
765
- return view.capture(opts);
928
+ return this.withRecovery(view => view.capture(opts));
766
929
  }
767
930
  async flushAuth() {
768
- const view = await this.materializeOnce();
769
- return view.flushAuth();
931
+ return this.withRecovery(view => view.flushAuth());
770
932
  }
771
933
  async restoreAuth(cookies) {
772
- const view = await this.materializeOnce();
773
- return view.restoreAuth(cookies);
934
+ return this.withRecovery(view => view.restoreAuth(cookies));
774
935
  }
775
936
  }
776
937
  /** Reject a promise if it does not settle within the budget. */
@@ -784,3 +945,13 @@ function withTimeout(promise, ms, message) {
784
945
  export function defaultHostMainPath() {
785
946
  return fileURLToPath(new URL('./host-main.js', import.meta.url));
786
947
  }
948
+ /**
949
+ * Test/diagnostic hooks (mirrors tool-browser's `internals` convention).
950
+ * `isBareElectron` is the packaged-app discriminator behind the host-reuse
951
+ * steps; `resolveElectronPath` is the full resolution order.
952
+ */
953
+ export const internals = {
954
+ isBareElectron,
955
+ resolveElectronPath,
956
+ resolveElectronPathImpl,
957
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",