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