dsh-builtin-browser 0.1.8 → 0.1.10

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.
@@ -21,6 +21,9 @@ import { createConnection } from 'node:net'
21
21
  /** CDP protocol version attached to every view's debugger. */
22
22
  const CDP_VERSION = '1.3'
23
23
 
24
+ /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
25
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
26
+
24
27
  /** One view: the Electron object plus its CDP-backed surface. */
25
28
  interface HostView {
26
29
  readonly webContentsView: WebContentsView
@@ -59,12 +62,16 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
59
62
  window.on('closed', () => { window = undefined })
60
63
  }
61
64
  const view = new WebContentsView()
65
+ // Attach the debugger BEFORE the view can be seen: an attach failure
66
+ // then leaves nothing in the window (no visible ghost view).
67
+ view.webContents.debugger.attach(CDP_VERSION)
68
+ // New views start hidden: with several sessions/tabs only the shown
69
+ // one may be visible (they stack in contentView child order).
62
70
  view.setVisible(false)
63
71
  window.contentView.addChildView(view)
64
72
  const [width, height] = window.getContentSize()
65
73
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
66
- view.setVisible(true)
67
- view.webContents.debugger.attach(CDP_VERSION)
74
+ if (views.size === 0) view.setVisible(true)
68
75
  views.set(viewId, { webContentsView: view })
69
76
  reply(msg.id, { ok: true })
70
77
  return
@@ -86,7 +93,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
86
93
  const viewId = msg.viewId
87
94
  if (viewId === undefined) throw new Error('showView missing viewId')
88
95
  const entry = views.get(viewId)
89
- if (entry !== undefined) entry.webContentsView.setVisible(true)
96
+ if (entry !== undefined) {
97
+ // Hide every other view, then show and RAISE the target so the
98
+ // human actually sees the active tab/session (topmost child wins).
99
+ for (const v of views.values()) {
100
+ if (v === entry) continue
101
+ try { v.webContentsView.setVisible(false) } catch { /* destroyed */ }
102
+ }
103
+ entry.webContentsView.setVisible(true)
104
+ if (window !== undefined) {
105
+ window.contentView.removeChildView(entry.webContentsView)
106
+ window.contentView.addChildView(entry.webContentsView)
107
+ }
108
+ }
90
109
  reply(msg.id, { ok: true })
91
110
  return
92
111
  }
@@ -120,6 +139,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
120
139
  if (!r.ok) throw new Error('HTTP ' + r.status)
121
140
  const b = await r.arrayBuffer()
122
141
  const bytes = new Uint8Array(b)
142
+ if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
123
143
  let bin = ''
124
144
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
125
145
  return btoa(bin)
@@ -143,16 +163,21 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
143
163
  // Export the session's cookies so login state can be saved/restored
144
164
  // across browser hosts (or shared with another machine).
145
165
  const cookies = await entry.webContentsView.webContents.session.cookies.get({})
146
- const exported = cookies.map(c => ({
147
- url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
148
- name: c.name,
149
- value: c.value,
150
- domain: c.domain,
151
- path: c.path,
152
- secure: c.secure,
153
- httpOnly: c.httpOnly,
154
- expirationDate: c.expirationDate,
155
- }))
166
+ const exported = cookies.map(c => {
167
+ const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain
168
+ // IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
169
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
170
+ return {
171
+ url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
172
+ name: c.name,
173
+ value: c.value,
174
+ domain: c.domain,
175
+ path: c.path,
176
+ secure: c.secure,
177
+ httpOnly: c.httpOnly,
178
+ expirationDate: c.expirationDate,
179
+ }
180
+ })
156
181
  reply(msg.id, { ok: true, result: { cookies: exported } })
157
182
  return
158
183
  }
@@ -163,6 +188,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
163
188
  const entry = views.get(viewId)
164
189
  if (entry === undefined) throw new Error(`restoreAuth: unknown view ${viewId}`)
165
190
  if (!Array.isArray(cookies)) throw new Error('restoreAuth missing cookies array')
191
+ let restored = 0
166
192
  for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
167
193
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
168
194
  await entry.webContentsView.webContents.session.cookies.set({
@@ -175,8 +201,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
175
201
  ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
176
202
  ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
177
203
  })
204
+ restored++
178
205
  }
179
- reply(msg.id, { ok: true, result: { restored: (cookies as unknown[]).length } })
206
+ reply(msg.id, { ok: true, result: { restored } })
180
207
  return
181
208
  }
182
209
  default:
@@ -220,6 +247,12 @@ void app.whenReady().then(() => {
220
247
  socket.on('error', error => {
221
248
  process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
222
249
  })
250
+ // The parent owns our lifetime: when it closes the socket (dispose) or dies
251
+ // without cleanup, exit so no zombie Electron window is left behind.
252
+ socket.on('close', () => {
253
+ process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
254
+ app.exit(0)
255
+ })
223
256
  // Keep the process alive until the parent closes the socket or kills us.
224
257
  })
225
258
 
@@ -39,11 +39,11 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
39
39
  const lower = (title + '\\n' + bodyText).toLowerCase()
40
40
  const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
41
41
  const framesLower = frameSrcs.toLowerCase()
42
- const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
42
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
43
43
  || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
44
44
  const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
45
45
  const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
46
- const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
46
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
47
47
  const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
48
48
  if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
49
49
  if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
@@ -114,8 +114,10 @@ interface Session {
114
114
  readonly id: BrowserSessionId
115
115
  readonly tabs: Tab[]
116
116
  activeIndex: number
117
- /** Chronological operation log (navigate/execute/click/type/screenshot). */
117
+ /** Chronological operation log (navigate/execute/click/type/fill/download/auth). */
118
118
  readonly history: BrowserHistoryEntry[]
119
+ /** Monotonic sequence counter for history entries (survives truncation). */
120
+ nextSeq: number
119
121
  }
120
122
 
121
123
  /** Provider config: navigation admission defaults and snapshot caps. */
@@ -207,7 +209,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
207
209
  open(): Promise<BrowserSessionId> {
208
210
  const handle = this.host.createView()
209
211
  const id = `browser:${randomUUID()}`
210
- this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
212
+ this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
211
213
  return Promise.resolve(id)
212
214
  }
213
215
 
@@ -302,15 +304,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
302
304
  }
303
305
  }
304
306
  signal?.throwIfAborted()
305
- const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams)
307
+ // Page.navigate can hang on an unreachable/slow host; bound it like the
308
+ // evaluate paths so a wedged navigation surfaces as an error instead of
309
+ // blocking the tool call forever.
310
+ const timeoutMs = 30_000
311
+ const result = await withTimeout(
312
+ handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
313
+ timeoutMs,
314
+ signal,
315
+ `browser: navigation timed out after ${timeoutMs}ms`,
316
+ )
306
317
  // Page.navigate resolves even when the navigation fails; surface the
307
318
  // failure instead of leaving a silent white screen.
308
319
  const errorText = (result as { errorText?: string }).errorText
309
320
  if (typeof errorText === 'string' && errorText !== '') {
310
- this.record(s, 'navigate', { url }, false, { error: errorText })
311
321
  throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
312
322
  }
313
323
  this.record(s, 'navigate', { url }, true)
324
+ this.showActive(s)
314
325
  } catch (error) {
315
326
  if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
316
327
  this.record(s, 'navigate', { url }, false, { error: String(error) })
@@ -362,9 +373,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
362
373
  return { ok: false, exception }
363
374
  }
364
375
  const value = (result.result as { value?: unknown } | undefined)?.value ?? null
365
- this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
376
+ this.record(s, 'execute', {
377
+ script: request.script,
378
+ ...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
379
+ }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
366
380
  return { ok: true, value }
367
381
  } catch (error) {
382
+ if (error instanceof Error && error.name === 'TimeoutError') {
383
+ throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
384
+ }
368
385
  throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
369
386
  }
370
387
  }
@@ -476,13 +493,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
476
493
  return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
477
494
  })()`
478
495
  // Honor a per-call timeout: content evaluation can hang on a heavy page,
479
- // so a caller-supplied budget bounds it. AbortSignal.any merges the
480
- // caller's signal with a timeout.
496
+ // so a caller-supplied budget bounds it. Unlike a bare signal entry check,
497
+ // withTimeout also interrupts a call already in flight.
481
498
  const timeoutMs = request.timeoutMs ?? 30_000
482
- const timeoutSignal = AbortSignal.timeout(timeoutMs)
483
- const result = signal !== undefined
484
- ? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
485
- : await handleSendEvaluate(tab.handle, script, timeoutSignal)
499
+ const result = await withTimeout(
500
+ handleSendEvaluate(tab.handle, script),
501
+ timeoutMs,
502
+ signal,
503
+ `browser: content timed out after ${timeoutMs}ms`,
504
+ )
486
505
  if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
487
506
  const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
488
507
  if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
@@ -505,7 +524,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
505
524
  const { handle } = this.activeTab(s)
506
525
  signal?.throwIfAborted()
507
526
  await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
508
- this.record(s, 'type', { text: request.text.slice(0, 200) }, true)
527
+ // Store the full text so replay re-issues the same input; the history
528
+ // tool truncates long values when rendering.
529
+ this.record(s, 'type', { text: request.text }, true)
509
530
  }
510
531
 
511
532
  /**
@@ -565,7 +586,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
565
586
  return vis.length > 0 ? vis : all
566
587
  }
567
588
  for (const spec of specs) {
568
- const els = candidates(spec)
589
+ let els
590
+ try {
591
+ els = candidates(spec)
592
+ } catch (e) {
593
+ // A malformed selector must not abort the whole batch; report the
594
+ // field as failed and continue with the rest.
595
+ out.push({ ok: false, error: String(e), target: describe(spec) })
596
+ continue
597
+ }
569
598
  if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
570
599
  const el = els[0]
571
600
  const tag = el.tagName
@@ -625,7 +654,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
625
654
  let submitted = false
626
655
  if (${submitFlag}) {
627
656
  let last = null
628
- for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
657
+ for (const spec of specs) {
658
+ try { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } } catch { /* skip */ }
659
+ }
629
660
  const form = last && (last.form || last.closest('form'))
630
661
  if (form) { form.requestSubmit(); submitted = true }
631
662
  }
@@ -662,7 +693,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
662
693
  if (typeof downloadable.download !== 'function') {
663
694
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
664
695
  }
665
- await downloadable.download(request.url, request.savePath)
696
+ // The child fetches in-page with awaitPromise; a slow/hung network can
697
+ // block it well past the tool budget, so bound it like every other call.
698
+ const timeoutMs = 60_000
699
+ await withTimeout(
700
+ downloadable.download(request.url, request.savePath),
701
+ timeoutMs,
702
+ signal,
703
+ `browser: download timed out after ${timeoutMs}ms`,
704
+ )
666
705
  this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
667
706
  return { path: request.savePath }
668
707
  }
@@ -678,7 +717,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
678
717
  if (typeof host.flushAuth !== 'function') {
679
718
  throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
680
719
  }
681
- const cookies = await host.flushAuth()
720
+ const timeoutMs = 30_000
721
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
682
722
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
683
723
  return cookies
684
724
  }
@@ -691,7 +731,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
691
731
  if (typeof host.restoreAuth !== 'function') {
692
732
  throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
693
733
  }
694
- const restored = await host.restoreAuth(cookies)
734
+ const timeoutMs = 30_000
735
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
695
736
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
696
737
  return restored
697
738
  }
@@ -710,7 +751,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
710
751
  // a clip this yields the full-page image (CDP default is the viewport).
711
752
  params.captureBeyondViewport = true
712
753
  }
713
- const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params)
754
+ // Bound the capture: a view that has not painted can wedge
755
+ // Page.captureScreenshot indefinitely (observed after view re-showing).
756
+ const timeoutMs = 30_000
757
+ const result = await withTimeout(
758
+ handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
759
+ timeoutMs,
760
+ signal,
761
+ `browser: screenshot timed out after ${timeoutMs}ms`,
762
+ )
714
763
  const data = result.data
715
764
  if (typeof data !== 'string') {
716
765
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
@@ -739,7 +788,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
739
788
  detail?: { result?: string; error?: string },
740
789
  ): void {
741
790
  const entry: BrowserHistoryEntry = {
742
- seq: s.history.length + 1,
791
+ seq: s.nextSeq++,
743
792
  action,
744
793
  params,
745
794
  ok,
@@ -796,8 +845,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
796
845
  case 'execute': {
797
846
  const script = entry.params.script
798
847
  if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
799
- const result = await this.execute(session, { script })
800
- this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
848
+ const recordedArgs = entry.params.args
849
+ const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a): a is string => typeof a === 'string') : undefined
850
+ const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} })
851
+ this.record(s, 'replay', { seq, of: entry.action, script, ...args !== undefined && args.length > 0 ? { args } : {} }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
801
852
  return
802
853
  }
803
854
  default:
@@ -846,7 +897,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
846
897
 
847
898
  /** Read the current URL of a view through CDP. */
848
899
  private async currentUrl(handle: ElectronViewHandle): Promise<string> {
849
- const result = await handleSendEvaluate(handle, 'location.href')
900
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
901
+ const timeoutMs = 10_000
902
+ const result = await withTimeout(
903
+ handleSendEvaluate(handle, 'location.href'),
904
+ timeoutMs,
905
+ undefined,
906
+ `browser: url read timed out after ${timeoutMs}ms`,
907
+ )
850
908
  return result.ok && typeof result.value === 'string' ? result.value : ''
851
909
  }
852
910
  }
@@ -872,7 +930,12 @@ function withTimeout<T>(
872
930
  const timer = setTimeout(() => {
873
931
  if (done) return
874
932
  done = true
875
- reject(new Error(message))
933
+ // A fired timeout must also release the abort listener; { once: true }
934
+ // only releases it on the next abort, which may never come.
935
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
936
+ const error = new Error(message)
937
+ error.name = 'TimeoutError'
938
+ reject(error)
876
939
  }, ms)
877
940
  const finish = (fn: () => void): void => {
878
941
  if (done) return
@@ -2,9 +2,9 @@
2
2
  * Self-hosted Electron browser host (parent side): an
3
3
  * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
4
  * Electron child process (host-main.js) and drives it over line-delimited
5
- * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
- * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
- * enough — the browser window appears on first use.
5
+ * JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
6
+ * surfaces without a desktop shell's electronViewHost (plain dsh web):
7
+ * installing the plugin is enough — the browser window appears on first use.
8
8
  *
9
9
  * Protocol (one JSON object per line, both directions):
10
10
  * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
@@ -28,6 +28,9 @@ import type { ExportedCookie } from '../browser/types.ts'
28
28
  /** How long to wait for the child to signal readiness before failing. */
29
29
  const READY_TIMEOUT_MS = 20_000
30
30
 
31
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
32
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
33
+
31
34
  /**
32
35
  * Locate the Electron binary, trying, in order:
33
36
  * 1. require('electron') from this module (optional peer dependency),
@@ -159,10 +162,13 @@ class ElectronChildClient {
159
162
  private socket: import('node:net').Socket | undefined
160
163
  private connected = false
161
164
  private outbox: string[] = []
165
+ /** Set once the child has exited; further calls fail fast instead of queueing. */
166
+ private dead = false
162
167
 
163
168
  constructor(
164
169
  private readonly hostMainPath: string,
165
170
  private readonly port: number,
171
+ private readonly onExit?: () => void,
166
172
  ) {
167
173
  const electron = resolveElectronPath()
168
174
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
@@ -181,24 +187,45 @@ class ElectronChildClient {
181
187
  // Diagnostics only; never parse stderr as protocol.
182
188
  process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
183
189
  })
190
+ // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
191
+ // that would crash the whole DSH process.
192
+ this.child.on('error', error => {
193
+ process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
194
+ this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
195
+ })
184
196
  this.child.on('exit', (code, signal) => {
185
- const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`)
186
- for (const pending of this.pending.values()) pending.reject(err)
187
- this.pending.clear()
197
+ this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
188
198
  })
189
199
  }
190
200
 
201
+ /** Reject everything in flight, mark the client dead, and notify the host. */
202
+ private fail(err: Error): void {
203
+ if (this.dead) return
204
+ this.dead = true
205
+ this.connected = false
206
+ for (const pending of this.pending.values()) pending.reject(err)
207
+ this.pending.clear()
208
+ this.outbox = []
209
+ this.onExit?.()
210
+ }
211
+
191
212
  /** Accept the child's connection (called by the server). */
192
213
  attach(socket: import('node:net').Socket): void {
193
214
  this.socket = socket
194
215
  this.connected = true
195
216
  socket.setEncoding('utf8')
217
+ // Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
218
+ // uncaught 'error' event and crashes the whole DSH process; 'close' below
219
+ // does the cleanup.
220
+ socket.on('error', error => {
221
+ process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
222
+ })
196
223
  socket.on('data', chunk => this.onData(chunk))
197
224
  socket.on('close', () => {
198
225
  this.connected = false
199
- const err = new Error('dsh-builtin-browser: browser host connection closed')
200
- for (const pending of this.pending.values()) pending.reject(err)
201
- this.pending.clear()
226
+ if (!this.dead) {
227
+ this.fail(new Error('dsh-builtin-browser: browser host connection closed'))
228
+ }
202
229
  })
203
230
  // Flush anything queued while disconnected.
204
231
  if (this.outbox.length > 0) {
@@ -209,6 +236,14 @@ class ElectronChildClient {
209
236
 
210
237
  private onData(chunk: string | Buffer): void {
211
238
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
239
+ // Safety net: a pathological child (or a reply larger than expected)
240
+ // must not grow the parent's memory without bound. The child caps
241
+ // downloads at 256 MiB, so a healthy stream never approaches this.
242
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
243
+ this.buffer = ''
244
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`))
245
+ return
246
+ }
212
247
  let nl: number
213
248
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
214
249
  const line = this.buffer.slice(0, nl).trim()
@@ -232,6 +267,9 @@ class ElectronChildClient {
232
267
 
233
268
  /** Send one command and await the reply. */
234
269
  call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
270
+ if (this.dead) {
271
+ return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'))
272
+ }
235
273
  const id = this.nextId++
236
274
  const line = JSON.stringify({ id, op, ...payload })
237
275
  return new Promise<T>((resolve, reject) => {
@@ -292,14 +330,29 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
292
330
  private pendingSocket: Socket | undefined
293
331
  private readonly views = new Map<string, ElectronViewHandle>()
294
332
  private readyPromise: Promise<void> | undefined
333
+ private disposed = false
295
334
 
296
335
  constructor(private readonly hostMainPath: string) {}
297
336
 
298
- /** Ensure the child is up and ready (lazy on first use). */
337
+ /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
299
338
  private ready(): Promise<void> {
300
339
  if (this.readyPromise !== undefined) return this.readyPromise
301
- this.readyPromise = this.start()
302
- return this.readyPromise
340
+ const started = this.start()
341
+ const wrapped = started.catch(error => {
342
+ // A failed startup must not poison the host forever: tear down whatever
343
+ // was half-created and let the next call retry from scratch.
344
+ if (this.readyPromise === wrapped) {
345
+ this.readyPromise = undefined
346
+ this.client?.kill()
347
+ this.client = undefined
348
+ this.server?.close()
349
+ this.server = undefined
350
+ this.pendingSocket = undefined
351
+ }
352
+ throw error
353
+ })
354
+ this.readyPromise = wrapped
355
+ return wrapped
303
356
  }
304
357
 
305
358
  private async start(): Promise<void> {
@@ -312,10 +365,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
312
365
  server.once('error', reject)
313
366
  server.listen(0, '127.0.0.1', () => resolve())
314
367
  })
368
+ // A later server error (rare on a loopback ephemeral port) must not crash
369
+ // the process; the client's fail path handles the actual recovery.
370
+ server.on('error', error => {
371
+ process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`)
372
+ })
315
373
  const address = server.address()
316
374
  const port = typeof address === 'object' && address !== null ? address.port : 0
317
375
  this.server = server
318
- this.client = new ElectronChildClient(this.hostMainPath, port)
376
+ this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit())
319
377
  if (this.pendingSocket !== undefined) {
320
378
  this.client.attach(this.pendingSocket)
321
379
  this.pendingSocket = undefined
@@ -324,6 +382,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
324
382
  await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready')
325
383
  }
326
384
 
385
+ /** The child died: tear down so the next use starts a fresh child. */
386
+ private onChildExit(): void {
387
+ if (this.disposed) return
388
+ this.client = undefined
389
+ this.server?.close()
390
+ this.server = undefined
391
+ this.pendingSocket = undefined
392
+ this.readyPromise = undefined
393
+ // Keep the views map: handles still resolve to ids; a fresh child simply
394
+ // has no such views yet, and reset_session reopens clean sessions.
395
+ }
396
+
327
397
  createView(): ElectronViewHandle {
328
398
  // The seam is synchronous; the provider uses the handle immediately, so
329
399
  // commands are deferred until the child is up and the view materialized.
@@ -338,13 +408,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
338
408
  const client = this.client
339
409
  if (client === undefined) throw new Error('browser host unavailable')
340
410
  await client.call('createView', { viewId: id })
411
+ // If the view was destroyed while the createView RPC was in flight, do
412
+ // not re-insert a stale entry that would resurrect a dead child view.
413
+ if (this.views.get(id) === undefined) {
414
+ throw new Error('browser: view destroyed while starting')
415
+ }
341
416
  const view = new RemoteView(id, client)
342
417
  this.views.set(id, view)
343
418
  return view
344
419
  }
345
420
 
346
421
  showView(handle: ElectronViewHandle): void {
347
- void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }))
422
+ // Fire-and-forget by design (visibility is best-effort), but a rejected
423
+ // promise must not become an unhandled rejection (crash on Node >= 15).
424
+ void this.ready()
425
+ .then(() => this.client?.call('showView', { viewId: handle.id }))
426
+ .catch(() => { /* host unavailable */ })
348
427
  }
349
428
 
350
429
  destroyView(handle: ElectronViewHandle): void {
@@ -358,6 +437,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
358
437
 
359
438
  /** Shut the child and the RPC server down. */
360
439
  dispose(): void {
440
+ this.disposed = true
361
441
  this.client?.kill()
362
442
  this.client = undefined
363
443
  this.server?.close()
@@ -376,30 +456,40 @@ class DeferredRemoteView implements ElectronViewHandle {
376
456
  private readonly materialize: () => Promise<RemoteView>,
377
457
  ) {}
378
458
 
459
+ /**
460
+ * Materialize once and cache: every sendCommand on the same handle must
461
+ * target the SAME child view (re-materializing would re-run createView and
462
+ * duplicate the window). A FAILED materialization is reset so a later call
463
+ * (e.g. after the host restarted) can retry instead of being poisoned.
464
+ */
465
+ private materializeOnce(): Promise<RemoteView> {
466
+ if (this.materialized === undefined) {
467
+ const pending = this.materialize()
468
+ this.materialized = pending.catch(error => {
469
+ if (this.materialized === pending) this.materialized = undefined
470
+ throw error
471
+ })
472
+ }
473
+ return this.materialized
474
+ }
475
+
379
476
  async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
380
- // Materialize once and cache: every sendCommand on the same handle must
381
- // target the SAME child view. Re-materializing would re-run createView
382
- // (duplicate window) and drop the established view.
383
- this.materialized ??= this.materialize()
384
- const view = await this.materialized
477
+ const view = await this.materializeOnce()
385
478
  return view.sendCommand(method, params)
386
479
  }
387
480
 
388
481
  async download(url: string, savePath: string): Promise<void> {
389
- this.materialized ??= this.materialize()
390
- const view = await this.materialized
482
+ const view = await this.materializeOnce()
391
483
  return view.download(url, savePath)
392
484
  }
393
485
 
394
486
  async flushAuth(): Promise<ExportedCookie[]> {
395
- this.materialized ??= this.materialize()
396
- const view = await this.materialized
487
+ const view = await this.materializeOnce()
397
488
  return view.flushAuth()
398
489
  }
399
490
 
400
491
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
401
- this.materialized ??= this.materialize()
402
- const view = await this.materialized
492
+ const view = await this.materializeOnce()
403
493
  return view.restoreAuth(cookies)
404
494
  }
405
495
  }