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.
@@ -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
- async reapIdleTabs() {
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
- return this.starting
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
- * @param {number} [ms] - how long a browser nobody watches may stay up.
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 = IDLE_MS) {
3093
- this.idleAt = Date.now() + ms
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
- /* Someone is watching the mirror: the page stays up, and the clock starts
3098
- again so the browser is closed only once it is truly unused. */
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 close itself if nobody came back, and why: 0 means the
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.idle === null || this.stopping === true ? 0 : Math.max(0, Number(this.idleAt ?? 0) - Date.now()),
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
  }