dsh-inner-browser-use 0.44.0 → 0.46.0
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.md +13 -2
- package/README.zh.md +12 -2
- package/docs/ARCHITECTURE.md +51 -4
- package/docs/ARTIFACTS.md +19 -14
- package/docs/BENCH.md +36 -0
- package/docs/FLASH.md +10 -0
- package/docs/RESOURCES.md +83 -0
- package/docs/VERIFY.md +61 -0
- package/index.js +1 -1
- package/package.json +13 -3
- package/pixels.js +217 -0
- package/rest.js +124 -0
- package/scripts/check-flash-tools.mjs +6 -2
- package/scripts/check-look.mjs +86 -0
- package/scripts/check-rest.mjs +91 -0
- package/scripts/check-sites.mjs +94 -0
- package/scripts/soak.mjs +130 -0
- package/sidecar.js +348 -20
- package/tools.js +316 -25
package/scripts/soak.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does a long session grow without bound?
|
|
4
|
+
*
|
|
5
|
+
* node scripts/soak.mjs [cycles]
|
|
6
|
+
*
|
|
7
|
+
* One headless browser, one tab, one chat, over and over: open a page, read it,
|
|
8
|
+
* take a snapshot, read the same page again (the unchanged short-circuit), open
|
|
9
|
+
* the other page. Memory and page count are sampled every few cycles. The run
|
|
10
|
+
* fails when RSS climbs past the ceiling or a second tab appears, so a leak in
|
|
11
|
+
* the screencast, the CDP client or the read cache shows up as a number here
|
|
12
|
+
* instead of as a slow machine next week.
|
|
13
|
+
*
|
|
14
|
+
* Bounded on purpose: 24 cycles by default, about twenty seconds, no internet.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'node:http'
|
|
17
|
+
import { mkdtempSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-soak-'))
|
|
22
|
+
const cycles = Math.max(4, Math.min(Number(process.argv[2] ?? 24) || 24, 200))
|
|
23
|
+
|
|
24
|
+
const buttons = Array.from({ length: 120 }, (_, i) => `<button>action ${i + 1}</button>`).join('')
|
|
25
|
+
const words = Array.from({ length: 80 }, (_, i) => `Paragraph ${i + 1}: ` + 'the quick brown fox jumps over the lazy dog. '.repeat(6)).join('</p><p>')
|
|
26
|
+
const pages = {
|
|
27
|
+
'/app': `<!doctype html><meta charset="utf-8"><title>app shell</title><h1>app shell</h1><div>${buttons}</div>`,
|
|
28
|
+
'/doc': `<!doctype html><meta charset="utf-8"><title>long document</title><article><h1>long document</h1><p>${words}</p></article>`,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const server = createServer((request, response) => {
|
|
32
|
+
const body = pages[String(request.url ?? '').split('?')[0]] ?? pages['/app']
|
|
33
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
34
|
+
response.end(body)
|
|
35
|
+
})
|
|
36
|
+
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
37
|
+
const port = server.address().port
|
|
38
|
+
const appUrl = `http://127.0.0.1:${port}/app`
|
|
39
|
+
const docUrl = `http://127.0.0.1:${port}/doc`
|
|
40
|
+
|
|
41
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
42
|
+
const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
|
|
43
|
+
|
|
44
|
+
/* A short rest window on purpose: the run has to cross the sleep/wake chain
|
|
45
|
+
often enough to see it leak, not just remember that it works once. */
|
|
46
|
+
const sidecar = new Sidecar({ profile: 'soak', headless: true, restFloor: 1200, restCeiling: 1600 })
|
|
47
|
+
const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
|
|
48
|
+
const tool = (name) => tools.find((one) => one.name === name)
|
|
49
|
+
const cwd = mkdtempSync(join(tmpdir(), 'dsh-soak-cwd-'))
|
|
50
|
+
/* Same binding the plugin does: the chat and the project a call belongs to. */
|
|
51
|
+
const chat = 'session-soak'
|
|
52
|
+
const run = (name, args) => sessionScope.run(chat, () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: chat } })))
|
|
53
|
+
const text = (said) => String(said?.text ?? '')
|
|
54
|
+
|
|
55
|
+
/* macOS reports every renderer's RSS in full, shared pages included, so a
|
|
56
|
+
healthy headless tree sits far above what one page needs; the bar is a
|
|
57
|
+
ceiling this machine never approaches plus growth over the run. */
|
|
58
|
+
const ceilingMb = 2500
|
|
59
|
+
const samples = []
|
|
60
|
+
let failed = 0
|
|
61
|
+
const check = (ok, what) => {
|
|
62
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
|
|
63
|
+
if (!ok) failed += 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const startedAt = Date.now()
|
|
67
|
+
try {
|
|
68
|
+
await sidecar.launch('about:blank')
|
|
69
|
+
/* No mirror sink here: a watcher is what keeps a browser awake, and this run
|
|
70
|
+
has to reach the rest the way a chat with its pane closed does. */
|
|
71
|
+
await sidecar.ensure({ session: chat })
|
|
72
|
+
|
|
73
|
+
let reads = 0
|
|
74
|
+
let snapshots = 0
|
|
75
|
+
let rests = 0
|
|
76
|
+
for (let i = 0; i < cycles; i += 1) {
|
|
77
|
+
const url = i % 2 === 0 ? appUrl : docUrl
|
|
78
|
+
await run('browser_open', { url })
|
|
79
|
+
reads += text(await run('browser_view', {})).length > 0 ? 1 : 0
|
|
80
|
+
await run('browser_view', {})
|
|
81
|
+
if (i % 3 === 0) {
|
|
82
|
+
snapshots += 1
|
|
83
|
+
await run('browser_shot', {})
|
|
84
|
+
}
|
|
85
|
+
if (i % 2 === 1) await run('browser_read', {})
|
|
86
|
+
if (i % 4 === 3 || i === cycles - 1) {
|
|
87
|
+
/* sidecar.state() already reports what the whole process tree holds, so
|
|
88
|
+
no pid has to be found and no second sampling path exists. */
|
|
89
|
+
const state = await sidecar.state()
|
|
90
|
+
const mb = Math.round(Number(state?.memory ?? 0) / 1e6)
|
|
91
|
+
samples.push({ cycle: i + 1, mb, pages: Number(state?.pages ?? 0) })
|
|
92
|
+
console.log(` cycle ${String(i + 1).padStart(3)} · ${String(mb)} MB · pages ${String(state?.pages)}`)
|
|
93
|
+
}
|
|
94
|
+
/* Every eighth cycle the browser is left alone until its rest runs out:
|
|
95
|
+
the whole tree has to go, memory included, and the next cycle has to
|
|
96
|
+
bring it back without a second page or a lost address. */
|
|
97
|
+
if (i % 8 === 7) {
|
|
98
|
+
sidecar.touch(1200)
|
|
99
|
+
for (let step = 0; step < 40 && sidecar.alive; step += 1) {
|
|
100
|
+
await new Promise((settle) => setTimeout(settle, 250))
|
|
101
|
+
}
|
|
102
|
+
const down = await sidecar.state()
|
|
103
|
+
if (down.alive === true) throw new Error('the browser did not rest inside 10 s')
|
|
104
|
+
if (Number(down.memory ?? 0) !== 0) throw new Error('memory was still held while resting')
|
|
105
|
+
rests += 1
|
|
106
|
+
}
|
|
107
|
+
/* A quiet moment, so a leak in the close/reopen path is not hidden by the
|
|
108
|
+
next navigation arriving before the old page is gone. */
|
|
109
|
+
if (i % 6 === 5) await new Promise((settle) => setTimeout(settle, 250))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const first = samples[0] ?? { mb: 0 }
|
|
113
|
+
const last = samples.at(-1) ?? first
|
|
114
|
+
const growth = last.mb - first.mb
|
|
115
|
+
const seconds = Math.round((Date.now() - startedAt) / 100) / 10
|
|
116
|
+
check(reads > 0, `the browser answered every cycle (${String(reads)} of ${String(cycles)})`)
|
|
117
|
+
check(samples.every((one) => one.pages <= 1), `one page per chat is kept (max ${String(Math.max(...samples.map((one) => one.pages)))})`)
|
|
118
|
+
check(last.mb < ceilingMb, `RSS stays inside this machine's ceiling (${String(last.mb)} < ${String(ceilingMb)} MB)`)
|
|
119
|
+
check(growth < 250, `RSS growth over ${String(cycles)} cycles is bounded (${growth >= 0 ? '+' : ''}${String(growth)} MB)`)
|
|
120
|
+
check(rests > 0, `the browser rested ${String(rests)} times and each rest released its memory`)
|
|
121
|
+
check(Number(last.pages ?? 0) <= 1, 'the rest and wake chain never left a second page behind')
|
|
122
|
+
console.log(`soak: ${failed === 0 ? 'ok' : `${String(failed)} failed`} · ${String(cycles)} cycles, ${String(seconds)} s, rss ${String(first.mb)} → ${String(last.mb)} MB, pages ${String(last.pages)}, ${String(snapshots)} shots, ${String(reads)} reads, ${String(rests)} rests`)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
failed += 1
|
|
125
|
+
console.log('FAIL ' + String(error?.stack ?? error))
|
|
126
|
+
} finally {
|
|
127
|
+
await sidecar.close?.().catch(() => {})
|
|
128
|
+
server.close()
|
|
129
|
+
}
|
|
130
|
+
process.exitCode = failed === 0 ? 0 : 1
|
package/sidecar.js
CHANGED
|
@@ -10,6 +10,7 @@ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFil
|
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { GapClock, MAX_REST_MS, MIN_REST_MS, decide } from './rest.js'
|
|
13
14
|
import { readyFlash } from './runtime/flash-setup.mjs'
|
|
14
15
|
import { readSealed, tightenDir, writeSealed } from './vault.js'
|
|
15
16
|
import { isMedia, kindOf } from './media.js?m=1'
|
|
@@ -110,6 +111,18 @@ const MAX_TABS = 6
|
|
|
110
111
|
/* A tab nobody has watched or driven for this long is closed even under the cap:
|
|
111
112
|
six chats can stay open for days, and each one is a renderer's worth of memory. */
|
|
112
113
|
const TAB_IDLE_MS = 10 * 60_000
|
|
114
|
+
/* Ceiling for the process tree, renderers included. macOS reports each
|
|
115
|
+
renderer's RSS as if it were private, so an ordinary one-page browser reads
|
|
116
|
+
as ~1.4 GB; the number is comparable across runs, which is what a limit
|
|
117
|
+
needs, rather than an accounting of what is really unique. Past the ceiling
|
|
118
|
+
tabs nobody watches go first, and the browser itself when nobody watches at
|
|
119
|
+
all. Raise it when a page really needs more. */
|
|
120
|
+
const BUDGET_MB = Number(process.env.DSH_INNER_BROWSER_MEMORY_MB ?? 2400) || 2400
|
|
121
|
+
/* How often the lifecycle is looked at, and how often memory is sampled. The
|
|
122
|
+
beat is what makes the clock survive a lost timer: a plugin reload, a laptop
|
|
123
|
+
asleep, a timer that fired into a stopped browser. */
|
|
124
|
+
const BEAT_MS = 5000
|
|
125
|
+
const SAMPLE_MS = 30_000
|
|
113
126
|
|
|
114
127
|
/** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
|
|
115
128
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
@@ -175,6 +188,19 @@ export function flashRuntime() {
|
|
|
175
188
|
return { binary: ready.electron, plugin: ready.plugin, version }
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
/**
|
|
192
|
+
* What the browser said about its own plugin list. A plugin runtime that opens
|
|
193
|
+
* its port but carries no PPAPI module looks fine from outside and plays
|
|
194
|
+
* nothing, so the list is read and turned into a sentence rather than trusted.
|
|
195
|
+
* @param {unknown} names - plugin names from the page.
|
|
196
|
+
* @returns {{ok: boolean, name: string}} whether a Flash plugin is there.
|
|
197
|
+
*/
|
|
198
|
+
export function flashVerdict(names) {
|
|
199
|
+
const list = (Array.isArray(names) ? names : []).map((one) => String(one ?? ''))
|
|
200
|
+
const found = list.find((one) => one.toLowerCase().includes('shockwave'))
|
|
201
|
+
return { ok: found !== undefined, name: found ?? '' }
|
|
202
|
+
}
|
|
203
|
+
|
|
178
204
|
/* A chat's tab address outlives a plugin reload: the browser is still running
|
|
179
205
|
with that tab, and the chat that returns should get its page back, not a
|
|
180
206
|
blank one. */
|
|
@@ -1471,6 +1497,35 @@ export class SidecarPage {
|
|
|
1471
1497
|
}
|
|
1472
1498
|
}
|
|
1473
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* Parks a page nobody is driving: timers and fetch callbacks stop, while the
|
|
1502
|
+
* DOM, the scroll position, the cookies and the CDP session stay. Freezing is
|
|
1503
|
+
* reversible in place, so bringing it back costs a protocol call rather than
|
|
1504
|
+
* a launch and a sign-in. Chrome's own docs define the two states.
|
|
1505
|
+
* @returns {Promise<void>} done, even on a page that cannot be frozen.
|
|
1506
|
+
*/
|
|
1507
|
+
async freeze() {
|
|
1508
|
+
if (this.frozen === true) return
|
|
1509
|
+
try {
|
|
1510
|
+
await this.cdp.send('Page.setWebLifecycleState', { state: 'frozen' })
|
|
1511
|
+
this.frozen = true
|
|
1512
|
+
this.casting = false
|
|
1513
|
+
} catch {
|
|
1514
|
+
/* A page between navigations refuses; it is not a reason to fail a call. */
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** @returns {Promise<void>} done, with the page running again. */
|
|
1519
|
+
async thaw() {
|
|
1520
|
+
if (this.frozen !== true) return
|
|
1521
|
+
this.frozen = false
|
|
1522
|
+
try {
|
|
1523
|
+
await this.cdp.send('Page.setWebLifecycleState', { state: 'active' })
|
|
1524
|
+
} catch {
|
|
1525
|
+
/* Already gone: the next call finds a dead page and replaces it. */
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1474
1529
|
async stopCast() {
|
|
1475
1530
|
if (!this.casting) return
|
|
1476
1531
|
this.casting = false
|
|
@@ -2118,6 +2173,9 @@ export class Sidecar {
|
|
|
2118
2173
|
/* A movie page needs a runtime Chrome no longer has; the switch is the
|
|
2119
2174
|
person's, and it swaps the browser rather than the protocol. */
|
|
2120
2175
|
this.flash = options.flash === undefined ? process.env.DSH_INNER_BROWSER_FLASH === '1' || readFlashChoice() : options.flash === true
|
|
2176
|
+
/* Whether the launched runtime really carries the plugin, asked once at
|
|
2177
|
+
launch: null means it has not been asked. */
|
|
2178
|
+
this.flashCheck = null
|
|
2121
2179
|
/* The Flash app serves its own frames; the screencast cannot see a plugin. */
|
|
2122
2180
|
this.flashBridge = null
|
|
2123
2181
|
this.headless = true
|
|
@@ -2133,6 +2191,36 @@ export class Sidecar {
|
|
|
2133
2191
|
enough that a stray click does not stall a run, long enough that the agent
|
|
2134
2192
|
does not type into a field the person is in the middle of filling. */
|
|
2135
2193
|
this.holdMs = 6000
|
|
2194
|
+
/* When the browser was last asked for anything, and the rhythm of those
|
|
2195
|
+
asks: the two numbers that decide when a pause has become a departure. */
|
|
2196
|
+
this.use = new GapClock()
|
|
2197
|
+
this.useAt = 0
|
|
2198
|
+
/* The moment this browser may be put down, or 0 while it has no clock. */
|
|
2199
|
+
this.restAt = 0
|
|
2200
|
+
/* The lifecycle beat, and what it last found. */
|
|
2201
|
+
this.beat = null
|
|
2202
|
+
this.resting = false
|
|
2203
|
+
this.memMb = 0
|
|
2204
|
+
this.memAt = 0
|
|
2205
|
+
this.pressure = 'ok'
|
|
2206
|
+
this.budgetMb = Math.max(200, Number(options.budgetMb ?? BUDGET_MB) || BUDGET_MB)
|
|
2207
|
+
/* A window this browser is never given less than, or more than: a test can
|
|
2208
|
+
shrink both, and a chat that works in fast steps still gets grace. */
|
|
2209
|
+
this.restFloor = Math.max(1000, Number(options.restFloor ?? MIN_REST_MS) || MIN_REST_MS)
|
|
2210
|
+
this.restCeiling = Math.max(this.restFloor, Number(options.restCeiling ?? MAX_REST_MS) || MAX_REST_MS)
|
|
2211
|
+
/* How often it has been put down and brought back, and how long the last
|
|
2212
|
+
wake took: a rest that costs more than it saves is a bug worth seeing. */
|
|
2213
|
+
this.rests = 0
|
|
2214
|
+
this.sleptAt = 0
|
|
2215
|
+
this.wakeMs = 0
|
|
2216
|
+
/* Restarts inside a window: three in ten minutes is a browser that is
|
|
2217
|
+
broken, not a browser that was needed. */
|
|
2218
|
+
this.crashes = []
|
|
2219
|
+
this.restartWindow = Math.max(10_000, Number(options.restartWindow ?? 10 * 60_000) || 10 * 60_000)
|
|
2220
|
+
/* The shutdown in flight, if any: a launch that starts while the last
|
|
2221
|
+
browser is still being put away would have its fresh socket closed by
|
|
2222
|
+
the one going out. */
|
|
2223
|
+
this.closing = null
|
|
2136
2224
|
}
|
|
2137
2225
|
|
|
2138
2226
|
/**
|
|
@@ -2397,15 +2485,19 @@ export class Sidecar {
|
|
|
2397
2485
|
* chats actually in use, which is what the memory number follows.
|
|
2398
2486
|
* @returns {Promise<void>} done.
|
|
2399
2487
|
*/
|
|
2400
|
-
|
|
2488
|
+
/**
|
|
2489
|
+
* Closes tabs nobody watches, keeping their address for the chat.
|
|
2490
|
+
* @param {boolean} [all] - close them whatever their age, for memory pressure.
|
|
2491
|
+
*/
|
|
2492
|
+
async reapIdleTabs(all = false) {
|
|
2401
2493
|
const now = Date.now()
|
|
2402
|
-
if (now - Number(this.reapedAt ?? 0) < 60_000) return
|
|
2494
|
+
if (now - Number(this.reapedAt ?? 0) < 60_000 && all !== true) return
|
|
2403
2495
|
this.reapedAt = now
|
|
2404
2496
|
for (const [id, page] of this.pages) {
|
|
2405
2497
|
const target = String(page.targetId ?? '')
|
|
2406
2498
|
if ((page.frames?.size ?? 0) !== 0 || target === '' || page.port === 0) continue
|
|
2407
2499
|
if (page === this.page) continue
|
|
2408
|
-
if (now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
|
|
2500
|
+
if (all !== true && now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
|
|
2409
2501
|
this.pages.delete(id)
|
|
2410
2502
|
/* The column is told the tab is gone, so a chat coming back to the front
|
|
2411
2503
|
does not re-expand a pane for a page that already went away. */
|
|
@@ -2504,6 +2596,9 @@ export class Sidecar {
|
|
|
2504
2596
|
if (id === '') return page
|
|
2505
2597
|
const mine = this.pageFor(id)
|
|
2506
2598
|
if (mine !== null) {
|
|
2599
|
+
/* A page parked while nobody watched has to be running before it can be
|
|
2600
|
+
driven; this is the whole point of freezing rather than closing it. */
|
|
2601
|
+
if (mine.frozen === true) await mine.thaw()
|
|
2507
2602
|
/* A chat's tab can die on its own: a crash, a browser another call
|
|
2508
2603
|
restarted, a tab closed by hand. Handing that corpse back leaves the
|
|
2509
2604
|
mirror on a blank picture forever, so it has to answer before it is
|
|
@@ -2533,6 +2628,26 @@ export class Sidecar {
|
|
|
2533
2628
|
return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
|
|
2534
2629
|
}
|
|
2535
2630
|
|
|
2631
|
+
/**
|
|
2632
|
+
* Records a browser that stopped working, and reports whether it has stopped
|
|
2633
|
+
* working so often that starting it again would only produce another crash
|
|
2634
|
+
* on the person's screen. Three in ten minutes is a broken browser, not a
|
|
2635
|
+
* browser the chat needed; saying so once is more useful than a loop.
|
|
2636
|
+
* @returns {boolean} true when it is time to stop trying automatically.
|
|
2637
|
+
*/
|
|
2638
|
+
died() {
|
|
2639
|
+
const now = Date.now()
|
|
2640
|
+
this.crashes = [...this.crashes.filter((at) => now - Number(at) < this.restartWindow), now]
|
|
2641
|
+
return this.crashes.length > 3
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/** @returns {Error} the error a chat sees instead of a fourth doomed launch. */
|
|
2645
|
+
tooManyCrashes() {
|
|
2646
|
+
const error = new Error('the browser has stopped 3 times in a few minutes; not starting it again — open it again when you need it')
|
|
2647
|
+
this.note(error.message)
|
|
2648
|
+
return error
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2536
2651
|
async ensurePage(options = {}) {
|
|
2537
2652
|
if (options.profile !== undefined) this.profile = options.profile
|
|
2538
2653
|
const wanted = options.headless === undefined ? this.headless : options.headless !== false
|
|
@@ -2582,6 +2697,7 @@ export class Sidecar {
|
|
|
2582
2697
|
try {
|
|
2583
2698
|
return await this.recover(wasAt)
|
|
2584
2699
|
} catch {
|
|
2700
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2585
2701
|
await this.shutdown()
|
|
2586
2702
|
return this.ensure({ ...options, url: wasAt })
|
|
2587
2703
|
}
|
|
@@ -2594,6 +2710,7 @@ export class Sidecar {
|
|
|
2594
2710
|
try {
|
|
2595
2711
|
return await this.recover(page.lastUrl || this.lastUrl)
|
|
2596
2712
|
} catch {
|
|
2713
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2597
2714
|
await this.shutdown()
|
|
2598
2715
|
return this.ensure({ ...options, url: page.lastUrl || this.lastUrl })
|
|
2599
2716
|
}
|
|
@@ -2622,20 +2739,57 @@ export class Sidecar {
|
|
|
2622
2739
|
this.note('renderer stopped answering; opening a fresh tab')
|
|
2623
2740
|
this.starting = this.recover(wasAt).catch((error) => {
|
|
2624
2741
|
this.note(String(error?.message ?? error))
|
|
2742
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2625
2743
|
return this.launch(wasAt)
|
|
2626
2744
|
}).finally(() => {
|
|
2627
2745
|
this.starting = null
|
|
2628
2746
|
})
|
|
2629
2747
|
return this.starting
|
|
2630
2748
|
}
|
|
2749
|
+
const asked = Date.now()
|
|
2631
2750
|
this.starting = this.launch(options.url).finally(() => {
|
|
2632
2751
|
this.starting = null
|
|
2633
2752
|
})
|
|
2634
|
-
|
|
2753
|
+
/* How long a rest actually cost is the number that decides whether resting
|
|
2754
|
+
was worth it; a silent multi-second wake is a regression, not a detail. */
|
|
2755
|
+
return this.starting.then((page) => {
|
|
2756
|
+
if (Number(this.sleptAt ?? 0) > 0) {
|
|
2757
|
+
this.wakeMs = Date.now() - asked
|
|
2758
|
+
this.note(`woke in ${String(this.wakeMs)} ms`)
|
|
2759
|
+
this.sleptAt = 0
|
|
2760
|
+
}
|
|
2761
|
+
return page
|
|
2762
|
+
})
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/**
|
|
2766
|
+
* Ask the Flash-era browser which plugins it actually loaded.
|
|
2767
|
+
* @returns {Promise<{ok: boolean, name: string} | null>} the answer, or null when it could not be asked.
|
|
2768
|
+
*/
|
|
2769
|
+
async checkFlash() {
|
|
2770
|
+
this.flashCheck = null
|
|
2771
|
+
const page = this.page
|
|
2772
|
+
if (page === null || page === undefined) return null
|
|
2773
|
+
try {
|
|
2774
|
+
const out = await page.cdp.send('Runtime.evaluate', {
|
|
2775
|
+
expression: 'JSON.stringify(Array.from(navigator.plugins).map((one) => one.name))',
|
|
2776
|
+
returnByValue: true,
|
|
2777
|
+
})
|
|
2778
|
+
const said = out?.result?.value
|
|
2779
|
+
const names = JSON.parse(typeof said === 'string' ? said : '[]')
|
|
2780
|
+
this.flashCheck = flashVerdict(names)
|
|
2781
|
+
} catch {
|
|
2782
|
+
/* A page that cannot answer is not evidence either way. */
|
|
2783
|
+
this.flashCheck = null
|
|
2784
|
+
}
|
|
2785
|
+
return this.flashCheck
|
|
2635
2786
|
}
|
|
2636
2787
|
|
|
2637
2788
|
/** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
|
|
2638
2789
|
async launch(url) {
|
|
2790
|
+
/* A rest and the next call can collide: wait for the browser that is going
|
|
2791
|
+
out to be gone before starting the one that is coming in. */
|
|
2792
|
+
if (this.closing !== null) await this.closing.catch(() => {})
|
|
2639
2793
|
/* Chromium 87 opening the profile a current Chrome owns would downgrade it
|
|
2640
2794
|
and lock it, so the Flash-era browser keeps a profile of its own. The two
|
|
2641
2795
|
never share a directory; a game signed into one is signed in there only. */
|
|
@@ -2656,6 +2810,10 @@ export class Sidecar {
|
|
|
2656
2810
|
await this.restoreSessionCookies()
|
|
2657
2811
|
await this.page.cast()
|
|
2658
2812
|
if (url !== undefined && url !== '') await this.page.navigate(url)
|
|
2813
|
+
/* A runtime that opens a port is not yet a runtime that plays: ask the page
|
|
2814
|
+
whether the plugin is really in it, so a broken install is said out loud
|
|
2815
|
+
instead of showing up as a black movie. */
|
|
2816
|
+
if (this.flash) await this.checkFlash()
|
|
2659
2817
|
this.touch()
|
|
2660
2818
|
/* Sign-ins outlive this browser: what works now is worth a copy, taken
|
|
2661
2819
|
while the cookies are known good rather than after they are lost. */
|
|
@@ -3086,27 +3244,153 @@ export class Sidecar {
|
|
|
3086
3244
|
}
|
|
3087
3245
|
|
|
3088
3246
|
/**
|
|
3089
|
-
* Starts the idle clock again
|
|
3090
|
-
*
|
|
3247
|
+
* Starts the idle clock again: the browser was just asked for something, and
|
|
3248
|
+
* that ask is also the measurement that teaches it how long this chat pauses
|
|
3249
|
+
* between steps while it is still working.
|
|
3250
|
+
* @param {number} [ms] - an explicit window, when a pane the person closed is
|
|
3251
|
+
* a stronger signal than the learned rhythm.
|
|
3091
3252
|
*/
|
|
3092
|
-
touch(ms
|
|
3093
|
-
|
|
3253
|
+
touch(ms) {
|
|
3254
|
+
const now = Date.now()
|
|
3255
|
+
this.use.record(now)
|
|
3256
|
+
this.useAt = now
|
|
3257
|
+
this.restAt = now + (Number.isFinite(ms) ? Math.max(0, Number(ms)) : this.restWindow())
|
|
3258
|
+
this.armRest()
|
|
3259
|
+
this.wakeClock()
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/**
|
|
3263
|
+
* Puts the precise edge on the clock: the beat would catch the deadline
|
|
3264
|
+
* anyway, this only makes it exact.
|
|
3265
|
+
*/
|
|
3266
|
+
armRest() {
|
|
3094
3267
|
if (this.idle !== null) clearTimeout(this.idle)
|
|
3268
|
+
if (this.restAt === 0) {
|
|
3269
|
+
this.idle = null
|
|
3270
|
+
return
|
|
3271
|
+
}
|
|
3272
|
+
/* The timer is the precise edge; the beat is what makes it survive a lost
|
|
3273
|
+
timer, a plugin reload or a laptop that slept through the deadline. */
|
|
3095
3274
|
this.idle = setTimeout(() => {
|
|
3096
3275
|
this.idle = null
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
let watched = this.page?.frames.size ?? 0
|
|
3100
|
-
for (const page of this.pages.values()) watched += page.frames?.size ?? 0
|
|
3101
|
-
if (watched > 0) {
|
|
3102
|
-
this.touch()
|
|
3103
|
-
return
|
|
3104
|
-
}
|
|
3105
|
-
void this.shutdown()
|
|
3106
|
-
}, ms)
|
|
3276
|
+
void this.beatNow().catch(() => {})
|
|
3277
|
+
}, Math.max(0, Number(this.restAt) - Date.now()))
|
|
3107
3278
|
this.idle.unref?.()
|
|
3108
3279
|
}
|
|
3109
3280
|
|
|
3281
|
+
/** @returns {number} ms of silence from this chat that still means "working". */
|
|
3282
|
+
restWindow() {
|
|
3283
|
+
return Math.min(this.restCeiling, Math.max(this.restFloor, this.use.restAfter()))
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
/** Starts the lifecycle beat while a browser is up. */
|
|
3287
|
+
wakeClock() {
|
|
3288
|
+
if (this.beat !== null || this.stopping === true) return
|
|
3289
|
+
this.beat = setInterval(() => {
|
|
3290
|
+
void this.beatNow().catch(() => {})
|
|
3291
|
+
}, BEAT_MS)
|
|
3292
|
+
this.beat.unref?.()
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/** Stops the beat and any pending rest: the browser is gone or handed over. */
|
|
3296
|
+
sleepClock() {
|
|
3297
|
+
if (this.beat !== null) clearInterval(this.beat)
|
|
3298
|
+
this.beat = null
|
|
3299
|
+
if (this.idle !== null) clearTimeout(this.idle)
|
|
3300
|
+
this.idle = null
|
|
3301
|
+
this.restAt = 0
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
/**
|
|
3305
|
+
* One lifecycle beat: has the pause become a departure, and does the browser
|
|
3306
|
+
* cost more than it is being used for.
|
|
3307
|
+
* @returns {Promise<void>} done, possibly with the browser put away.
|
|
3308
|
+
*/
|
|
3309
|
+
async beatNow() {
|
|
3310
|
+
if (!this.alive || this.stopping === true) {
|
|
3311
|
+
this.sleepClock()
|
|
3312
|
+
return
|
|
3313
|
+
}
|
|
3314
|
+
const now = Date.now()
|
|
3315
|
+
const watched = this.watched()
|
|
3316
|
+
const verdict = decide({
|
|
3317
|
+
now,
|
|
3318
|
+
lastUse: this.useAt,
|
|
3319
|
+
restAt: this.restAt,
|
|
3320
|
+
watched,
|
|
3321
|
+
quietAfter: this.use.quietAfter(),
|
|
3322
|
+
})
|
|
3323
|
+
if (verdict === 'rest') {
|
|
3324
|
+
this.rests += 1
|
|
3325
|
+
this.sleptAt = now
|
|
3326
|
+
/* The reason outlives the browser: a chat asking later finds out why the
|
|
3327
|
+
page it left is gone, instead of a silent blank column. */
|
|
3328
|
+
this.note(`unused for ${String(Math.round((now - Number(this.useAt)) / 1000))} s with no pane watching; put away`)
|
|
3329
|
+
await this.shutdown().catch(() => {})
|
|
3330
|
+
return
|
|
3331
|
+
}
|
|
3332
|
+
if (verdict !== 'rest') {
|
|
3333
|
+
/* Someone is still watching, or the chat is still working: the clock
|
|
3334
|
+
starts again from now, and the next edge is scheduled. */
|
|
3335
|
+
if (watched > 0) this.restAt = now + this.restWindow()
|
|
3336
|
+
if (this.idle === null && this.restAt > 0) this.armRest()
|
|
3337
|
+
}
|
|
3338
|
+
if (verdict === 'quiet') {
|
|
3339
|
+
if (this.resting !== true) {
|
|
3340
|
+
this.resting = true
|
|
3341
|
+
/* A picture nobody looks at still costs a screencast and an encoded
|
|
3342
|
+
frame per beat. The page stays, the painting stops. */
|
|
3343
|
+
for (const page of [this.page, ...this.pages.values()]) {
|
|
3344
|
+
if (page === null || page === undefined) continue
|
|
3345
|
+
if ((page.frames?.size ?? 0) !== 0) continue
|
|
3346
|
+
if (typeof page.stopCast !== 'function') continue
|
|
3347
|
+
await page.stopCast().catch(() => {})
|
|
3348
|
+
/* Chrome's own Page Lifecycle park: timers and fetches stop, the DOM,
|
|
3349
|
+
the scroll position and the CDP session stay. No competitor drives
|
|
3350
|
+
this state at all, and it is the difference between a tab that has
|
|
3351
|
+
to be reloaded and one that resumes where it was. */
|
|
3352
|
+
await page.freeze().catch(() => {})
|
|
3353
|
+
}
|
|
3354
|
+
this.restNote = 'no pane is watching; the page is parked and the mirror paused'
|
|
3355
|
+
}
|
|
3356
|
+
} else if (this.resting === true) {
|
|
3357
|
+
this.resting = false
|
|
3358
|
+
this.restNote = ''
|
|
3359
|
+
}
|
|
3360
|
+
await this.weigh(now, watched)
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
/**
|
|
3364
|
+
* What the browser costs, and what to do about it: tabs nobody watches go
|
|
3365
|
+
* first, and the browser itself goes when nobody watches at all.
|
|
3366
|
+
* @param {number} now - the moment of this look.
|
|
3367
|
+
* @param {number} watched - panes watching a picture right now.
|
|
3368
|
+
* @returns {Promise<void>} done.
|
|
3369
|
+
*/
|
|
3370
|
+
async weigh(now, watched) {
|
|
3371
|
+
if (now - Number(this.memAt ?? 0) < SAMPLE_MS) return
|
|
3372
|
+
this.memAt = now
|
|
3373
|
+
const pid = Number(this.child?.pid ?? 0)
|
|
3374
|
+
this.memMb = pid > 0 ? Math.round(treeBytes(pid) / 1024 / 1024) : 0
|
|
3375
|
+
if (this.memMb < this.budgetMb) {
|
|
3376
|
+
this.pressure = 'ok'
|
|
3377
|
+
return
|
|
3378
|
+
}
|
|
3379
|
+
if (this.pressure === 'ok') {
|
|
3380
|
+
this.pressure = 'tight'
|
|
3381
|
+
this.note(`holding ${String(this.memMb)} MB; closing tabs no pane is watching`)
|
|
3382
|
+
await this.trimTabs().catch(() => {})
|
|
3383
|
+
await this.reapIdleTabs(true).catch(() => {})
|
|
3384
|
+
return
|
|
3385
|
+
}
|
|
3386
|
+
if (watched === 0) {
|
|
3387
|
+
this.pressure = 'high'
|
|
3388
|
+
this.rests += 1
|
|
3389
|
+
this.sleptAt = Date.now()
|
|
3390
|
+
await this.shutdown().catch(() => {})
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3110
3394
|
/** @returns {Promise<void>} resolves once Chrome is gone. */
|
|
3111
3395
|
/**
|
|
3112
3396
|
* Leaves the browser running for whoever loads this plugin next, and puts the
|
|
@@ -3118,6 +3402,7 @@ export class Sidecar {
|
|
|
3118
3402
|
*/
|
|
3119
3403
|
async handoff() {
|
|
3120
3404
|
this.stopping = true
|
|
3405
|
+
this.sleepClock()
|
|
3121
3406
|
if (this.page !== null) await this.saveSessionCookies()
|
|
3122
3407
|
if (this.backup !== undefined) clearInterval(this.backup)
|
|
3123
3408
|
this.backup = undefined
|
|
@@ -3182,7 +3467,25 @@ export class Sidecar {
|
|
|
3182
3467
|
}
|
|
3183
3468
|
|
|
3184
3469
|
async shutdown() {
|
|
3470
|
+
/* One at a time: a rest that fires while a call already asked the browser
|
|
3471
|
+
to close must not close the socket that call is about to use. */
|
|
3472
|
+
if (this.closing !== null) return this.closing
|
|
3473
|
+
const going = this.putAway()
|
|
3474
|
+
this.closing = going
|
|
3475
|
+
try {
|
|
3476
|
+
await going
|
|
3477
|
+
} finally {
|
|
3478
|
+
if (this.closing === going) this.closing = null
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
/** Put the browser away once; `shutdown()` keeps two of these from overlapping.
|
|
3483
|
+
* @returns {Promise<void>} done. */
|
|
3484
|
+
async putAway() {
|
|
3185
3485
|
this.idleAt = 0
|
|
3486
|
+
/* Nothing to wait for once the browser is going: a beat that fired into a
|
|
3487
|
+
stopped browser would try to put it away a second time. */
|
|
3488
|
+
this.sleepClock()
|
|
3186
3489
|
/* A browser this process did not start belongs to somebody else — another
|
|
3187
3490
|
app on the same profile, or a script that connected to it — and closing
|
|
3188
3491
|
it would close the person's window. Let go of it instead: drop the socket
|
|
@@ -3326,6 +3629,8 @@ export class Sidecar {
|
|
|
3326
3629
|
alive: this.alive, // ui-copy-ignore
|
|
3327
3630
|
profile: this.profile, // ui-copy-ignore
|
|
3328
3631
|
headless: this.headless, // ui-copy-ignore
|
|
3632
|
+
flash: this.flash, // ui-copy-ignore
|
|
3633
|
+
flashCheck: this.flashCheck, // ui-copy-ignore
|
|
3329
3634
|
url: this.page?.url ?? '', // ui-copy-ignore
|
|
3330
3635
|
pages: this.pages.size,
|
|
3331
3636
|
title: this.page?.title ?? '',
|
|
@@ -3346,9 +3651,32 @@ export class Sidecar {
|
|
|
3346
3651
|
/* What this browser costs right now: the whole process tree, renderers
|
|
3347
3652
|
included. Reported so a chat can decide whether to let it rest. */
|
|
3348
3653
|
memory: this.alive ? treeBytes(Number(this.child?.pid ?? 0)) : 0,
|
|
3349
|
-
/* When it would
|
|
3654
|
+
/* When it would be put away if nobody came back, and why: 0 means the
|
|
3350
3655
|
browser is either watched or not on a clock. */
|
|
3351
|
-
closesIn: this.
|
|
3656
|
+
closesIn: this.restAt === 0 || this.stopping === true ? 0 : Math.max(0, Number(this.restAt) - Date.now()),
|
|
3657
|
+
/* The learned rhythm: the rest window this chat earned, and the quiet
|
|
3658
|
+
window it is given before the mirror is paused. */
|
|
3659
|
+
restIn: this.restAt === 0 ? 0 : Math.max(0, Number(this.restAt) - Date.now()),
|
|
3660
|
+
restWindow: this.use.restAfter(),
|
|
3661
|
+
quietAfter: this.use.quietAfter(),
|
|
3662
|
+
typicalGap: this.use.typical(),
|
|
3663
|
+
/* What the lifecycle beat last found. */
|
|
3664
|
+
resting: this.resting === true,
|
|
3665
|
+
restNote: this.restNote ?? '',
|
|
3666
|
+
/* How many times it has been put down, how long the last wake took, and
|
|
3667
|
+
how long it has been waiting for a wake right now. */
|
|
3668
|
+
rests: this.rests,
|
|
3669
|
+
sleptAt: this.sleptAt,
|
|
3670
|
+
wakeMs: this.wakeMs,
|
|
3671
|
+
/* Memory policy, so a chat can see the ladder before it is climbed. */
|
|
3672
|
+
memoryMb: this.memMb,
|
|
3673
|
+
budgetMb: this.budgetMb,
|
|
3674
|
+
pressure: this.pressure,
|
|
3675
|
+
/* Parked pages and the restarts that happened recently: a browser that
|
|
3676
|
+
has to be restarted three times in ten minutes is broken, and saying so
|
|
3677
|
+
is better than restarting it for ever. */
|
|
3678
|
+
parked: [this.page, ...this.pages.values()].filter((page) => page?.frozen === true).length,
|
|
3679
|
+
restarts: this.crashes.length,
|
|
3352
3680
|
error: this.alive ? '' : this.lastError,
|
|
3353
3681
|
}
|
|
3354
3682
|
}
|