dsh-inner-browser-use 0.45.0 → 0.47.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/rest.js ADDED
@@ -0,0 +1,124 @@
1
+ /* When to put the browser down.
2
+ *
3
+ * A fixed fifteen minutes is either too long (a chat that asks one question
4
+ * and walks away leaves a whole Chrome standing for a quarter of an hour) or
5
+ * too short (a long task that thinks for a minute between reads) — and the
6
+ * number that tells the two apart is already in the chat, in the gaps between
7
+ * one call and the next. So the browser learns its own rhythm: the 95th
8
+ * percentile of recent gaps says how long this chat pauses while it is still
9
+ * working, and the rest window is a multiple of that, floored and capped so a
10
+ * fast chat still gets a minute of grace and a slow one is not kept forever.
11
+ *
12
+ * Only the last few dozen gaps count. A stale rhythm from an hour ago is not
13
+ * evidence about now, and a sliding window forgets it without a decay constant
14
+ * to tune. The percentile is exact over that window: cheap at this size, and
15
+ * not an estimate that can drift.
16
+ */
17
+
18
+ /** Shortest rest window: a chat that works in two-second steps still gets grace. */
19
+ export const MIN_REST_MS = 90_000
20
+ /** Longest rest window: nothing learned may keep a browser up for longer than this. */
21
+ export const MAX_REST_MS = 15 * 60_000
22
+ /** Shortest quiet window: how soon an idle chat stops paying for a picture nobody sees. */
23
+ export const MIN_QUIET_MS = 20_000
24
+ /** Longest quiet window. */
25
+ export const MAX_QUIET_MS = 2 * 60_000
26
+ /** How many recent gaps make up the learned rhythm. */
27
+ const WINDOW = 64
28
+ /** Rest at this multiple of the learned p95: long enough to sit through a pause. */
29
+ const REST_FACTOR = 6
30
+ /** Quiet at this multiple: two slow steps in a row is a pause, not a break. */
31
+ const QUIET_FACTOR = 2
32
+
33
+ /**
34
+ * @param {number} value - what to keep in range.
35
+ * @param {number} low - smallest allowed.
36
+ * @param {number} high - largest allowed.
37
+ * @returns {number} `value` inside `[low, high]`.
38
+ */
39
+ function clamp(value, low, high) {
40
+ if (!Number.isFinite(value)) return low
41
+ return Math.min(high, Math.max(low, value))
42
+ }
43
+
44
+ /**
45
+ * The recent gaps between one use of this browser and the next, and what they
46
+ * say about how long a pause is still "working" rather than "gone".
47
+ */
48
+ export class GapClock {
49
+ /** @param {{window?: number}} [options] - how many gaps to keep. */
50
+ constructor(options = {}) {
51
+ /** @type {number} when the browser was last used, in ms. */
52
+ this.last = 0
53
+ /** @type {number[]} the recent gaps, oldest first. */
54
+ this.gaps = []
55
+ this.window = Math.max(4, Number(options.window ?? WINDOW) || WINDOW)
56
+ /** @type {number} how many gaps were seen at all, for reporting. */
57
+ this.seen = 0
58
+ }
59
+
60
+ /**
61
+ * Notes a use now and returns the gap it closed.
62
+ * @param {number} [now] - the moment of this use.
63
+ * @returns {number} the gap in ms, or 0 when there is no rhythm yet.
64
+ */
65
+ record(now = Date.now()) {
66
+ const at = Number(now)
67
+ const was = Number(this.last)
68
+ this.last = at
69
+ if (!Number.isFinite(was) || was <= 0 || !Number.isFinite(at) || at <= was) return 0
70
+ const gap = at - was
71
+ /* A gap longer than the longest rest window is a new sitting, not a step:
72
+ folding it in would teach the browser that hours are normal. */
73
+ if (gap > MAX_REST_MS) return 0
74
+ this.gaps.push(gap)
75
+ if (this.gaps.length > this.window) this.gaps.shift()
76
+ this.seen += 1
77
+ return gap
78
+ }
79
+
80
+ /**
81
+ * @param {number} q - a fraction in (0, 1].
82
+ * @returns {number} that percentile of the kept gaps, or 0 with no rhythm.
83
+ */
84
+ quantile(q = 0.95) {
85
+ const size = this.gaps.length
86
+ if (size === 0) return 0
87
+ const sorted = [...this.gaps].sort((a, b) => a - b)
88
+ const at = clamp(Math.ceil(q * size) - 1, 0, size - 1)
89
+ return sorted[at]
90
+ }
91
+
92
+ /** @returns {number} ms of silence after which the browser may be put down. */
93
+ restAfter() {
94
+ if (this.gaps.length === 0) return MIN_REST_MS
95
+ return clamp(this.quantile(0.95) * REST_FACTOR, MIN_REST_MS, MAX_REST_MS)
96
+ }
97
+
98
+ /** @returns {number} ms of silence after which an unwatched page may stop painting. */
99
+ quietAfter() {
100
+ if (this.gaps.length === 0) return MIN_QUIET_MS
101
+ return clamp(this.quantile(0.95) * QUIET_FACTOR, MIN_QUIET_MS, MAX_QUIET_MS)
102
+ }
103
+
104
+ /** @returns {number} the middle gap, for a report that has to be readable. */
105
+ typical() {
106
+ return this.quantile(0.5)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * What the browser should do about time passing right now.
112
+ * @param {{now: number, lastUse: number, restAt: number, watched: number, quietAfter: number}} state - clocks and viewers.
113
+ * @returns {'awake'|'quiet'|'rest'} `rest` means nobody is watching and the window ran out.
114
+ */
115
+ export function decide(state) {
116
+ const now = Number(state.now ?? 0)
117
+ const watched = Number(state.watched ?? 0)
118
+ const lastUse = Number(state.lastUse ?? 0)
119
+ const restAt = Number(state.restAt ?? 0)
120
+ if (watched > 0) return lastUse > 0 && now - lastUse >= Number(state.quietAfter ?? MIN_QUIET_MS) ? 'quiet' : 'awake'
121
+ if (restAt > 0 && now >= restAt) return 'rest'
122
+ if (lastUse > 0 && now - lastUse >= Number(state.quietAfter ?? MIN_QUIET_MS)) return 'quiet'
123
+ return 'awake'
124
+ }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * What this browser costs, in the same terms the neighbours are measured in.
4
+ *
5
+ * node scripts/bench-resource.mjs [--json out.json]
6
+ *
7
+ * Cold start, action round trip, idle memory and CPU with a page open, the
8
+ * reversible freeze, the rest, and the wake. One process tree, one page, the
9
+ * same protocol `docs/RESOURCES.md` uses for Playwright MCP and Chrome DevTools
10
+ * MCP — so the numbers can sit in one table.
11
+ */
12
+ import { createServer } from 'node:http'
13
+ import { execFileSync } from 'node:child_process'
14
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+
18
+ process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-bench-'))
19
+ const sink = (() => {
20
+ const at = process.argv.indexOf('--json')
21
+ return at === -1 ? null : process.argv[at + 1]
22
+ })()
23
+
24
+ const server = createServer((request, response) => {
25
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
26
+ response.end('<!doctype html><meta charset="utf-8"><title>bench</title><h1>bench</h1>' +
27
+ `<p>${'the page has to cost what a page costs. '.repeat(40)}</p>` +
28
+ '<ul>' + Array.from({ length: 60 }, (_, i) => `<li><a href="/row-${i}">row ${i}</a></li>`).join('') + '</ul>')
29
+ })
30
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
31
+ const url = `http://127.0.0.1:${server.address().port}/page`
32
+
33
+ const { Sidecar } = await import('../sidecar.js')
34
+ const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
35
+
36
+ const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
37
+ const sidecar = new Sidecar({ profile: 'bench-resource', headless: true, restFloor: 1200, restCeiling: 1600, budgetMb: 4000 })
38
+ const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
39
+ const tool = (name) => tools.find((one) => one.name === name)
40
+ const cwd = mkdtempSync(join(tmpdir(), 'dsh-bench-cwd-'))
41
+ const chat = 'session-bench-resource'
42
+ const run = (name, args) => sessionScope.run(chat, () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: chat } })))
43
+ const text = (said) => String(said?.text ?? '')
44
+
45
+ /* ps once per sample: pid, parent, cpu, rss. macOS sums shared pages per
46
+ process, so the total overstates real memory; the neighbours are measured the
47
+ same way, which is the only way the numbers compare. */
48
+ function tree(root) {
49
+ const rows = execFileSync('ps', ['-Ao', 'pid=,ppid=,%cpu=,rss='], { encoding: 'utf8' })
50
+ .trim().split('\n').map((line) => line.trim().split(/\s+/)).map(([pid, ppid, cpu, rss]) => ({ pid: Number(pid), ppid: Number(ppid), cpu: Number(cpu), rss: Number(rss) }))
51
+ const byParent = new Map()
52
+ for (const row of rows) {
53
+ if (!byParent.has(row.ppid)) byParent.set(row.ppid, [])
54
+ byParent.get(row.ppid).push(row)
55
+ }
56
+ const out = []
57
+ const walk = (pid) => {
58
+ for (const child of byParent.get(pid) ?? []) {
59
+ out.push(child)
60
+ walk(child.pid)
61
+ }
62
+ }
63
+ walk(root)
64
+ return out
65
+ }
66
+ function weigh() {
67
+ const root = sidecar.child?.pid
68
+ if (!root) return { mb: 0, cpu: 0, procs: 0 }
69
+ const rows = tree(root)
70
+ return {
71
+ mb: Math.round(rows.reduce((sum, row) => sum + row.rss, 0) / 1024),
72
+ cpu: Number(rows.reduce((sum, row) => sum + row.cpu, 0).toFixed(1)),
73
+ procs: rows.length + 1,
74
+ }
75
+ }
76
+ async function cpuOver(ms, every = 5000) {
77
+ const samples = []
78
+ const until = Date.now() + ms
79
+ while (Date.now() < until) {
80
+ samples.push(weigh().cpu)
81
+ await wait(Math.min(every, Math.max(0, until - Date.now())))
82
+ }
83
+ return {
84
+ samples_pct: samples,
85
+ avg_pct: Number((samples.reduce((a, b) => a + b, 0) / Math.max(1, samples.length)).toFixed(2)),
86
+ }
87
+ }
88
+
89
+ const report = { generated_at: new Date().toISOString(), host: { arch: process.arch, platform: process.platform }, version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version }
90
+ let failed = 0
91
+ const check = (ok, what) => {
92
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
93
+ if (!ok) failed += 1
94
+ }
95
+
96
+ try {
97
+ /* Keep the rest out of the way while the page is measured. */
98
+ const coldAt = Date.now()
99
+ await run('browser_open', { url })
100
+ const first = text(await run('browser_view', {}))
101
+ report.cold_start_ms = Date.now() - coldAt
102
+ check(first.includes('bench'), `cold start ${String(report.cold_start_ms)} ms`)
103
+ check(report.cold_start_ms < 6000, 'cold start under six seconds')
104
+
105
+ const readAt = Date.now()
106
+ const read = text(await run('browser_read', {}))
107
+ report.read_ms = Date.now() - readAt
108
+ report.read_chars = read.length
109
+ check(report.read_ms < 4000 && read.length > 200, `a read is ${String(report.read_ms)} ms and ${String(report.read_chars)} chars`)
110
+
111
+ const evalAt = Date.now()
112
+ for (let i = 0; i < 5; i += 1) await run('browser_eval', { code: 'document.title' })
113
+ report.action_rtt_ms = Math.round((Date.now() - evalAt) / 5)
114
+ check(report.action_rtt_ms < 500, `an action round trip is ${String(report.action_rtt_ms)} ms`)
115
+
116
+ const up = weigh()
117
+ report.open_rss_mb = up.mb
118
+ report.open_procs = up.procs
119
+ check(up.mb > 0, `a page open costs ${String(up.mb)} MB in ${String(up.procs)} processes`)
120
+
121
+ /* The first seconds after a load are the page finishing; the number that
122
+ matters is what it costs once it has finished. */
123
+ sidecar.touch(600_000)
124
+ await wait(5000)
125
+ report.idle_cpu = await cpuOver(30_000)
126
+ sidecar.touch(600_000)
127
+ await wait(2000)
128
+ const idle = weigh()
129
+ report.idle_rss_mb = idle.mb
130
+ check(true, `idle 30 s: ${String(idle.mb)} MB, ${String(report.idle_cpu.avg_pct)} % cpu`)
131
+
132
+ /* The reversible park: the page keeps its DOM, the process keeps its share. */
133
+ const frozenAt = Date.now()
134
+ await sidecar.pageFor(chat)?.freeze()
135
+ await wait(3000)
136
+ const frozen = weigh()
137
+ report.freeze_ms = Date.now() - frozenAt
138
+ report.frozen_rss_mb = frozen.mb
139
+ const frozenCpu = await cpuOver(6000)
140
+ report.frozen_cpu_pct = frozenCpu.avg_pct
141
+ check(report.frozen_rss_mb <= idle.mb + 8, `frozen costs ${String(report.frozen_rss_mb)} MB, was ${String(idle.mb)} MB`)
142
+
143
+ const thawAt = Date.now()
144
+ await sidecar.pageFor(chat)?.thaw()
145
+ const quick = text(await run('browser_view', {}))
146
+ report.thaw_ms = Date.now() - thawAt
147
+ check(quick.includes('bench') || quick.includes('nothing changed'), `thaw took ${String(report.thaw_ms)} ms`)
148
+
149
+ /* The full release and the wake that costs a cold launch. */
150
+ sidecar.touch(600)
151
+ for (let i = 0; i < 60; i += 1) {
152
+ await wait(200)
153
+ if ((await sidecar.state()).alive !== true) break
154
+ }
155
+ const asleep = await sidecar.state()
156
+ report.rested = asleep.alive !== true
157
+ report.asleep_mb = asleep.memory
158
+ check(report.rested && asleep.memory === 0, `rest releases everything (${String(asleep.memory)} bytes)`)
159
+
160
+ const wakeAt = Date.now()
161
+ const back = text(await run('browser_view', {}))
162
+ report.wake_ms = Date.now() - wakeAt
163
+ check(sidecar.pageFor(chat)?.url === url, `wake came back to ${String(sidecar.pageFor(chat)?.url)} in ${String(report.wake_ms)} ms`)
164
+ check(back.length > 0, 'the page still reads after the wake')
165
+ } finally {
166
+ server.close()
167
+ await sidecar.shutdown().catch(() => {})
168
+ }
169
+
170
+ console.log('\n' + JSON.stringify(report, null, 2))
171
+ if (sink !== null) writeFileSync(sink, JSON.stringify(report, null, 2) + '\n')
172
+ console.log(failed === 0 ? '\nresource numbers hold.' : `\n${String(failed)} bench checks failed.`)
173
+ process.exitCode = failed === 0 ? 0 : 1
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * What putting the browser to rest costs, and what waking it back up buys.
4
+ *
5
+ * node scripts/check-rest.mjs
6
+ *
7
+ * Opens a page, lets the rest window run out with nobody watching, and checks
8
+ * that the browser really went away — no process, no memory, a reason on the
9
+ * record — and that the next tool call brings it back with the same page, in
10
+ * well under the time a cold launch takes. Exits non-zero when rest gets
11
+ * expensive, or when waking loses the page.
12
+ */
13
+ import { createServer } from 'node:http'
14
+ import { mkdtempSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+
18
+ process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-rest-'))
19
+
20
+ const server = createServer((request, response) => {
21
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
22
+ response.end('<!doctype html><meta charset="utf-8"><title>rest check</title><h1>rest check</h1><p>this page has to survive a rest.</p>')
23
+ })
24
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
25
+ const url = `http://127.0.0.1:${server.address().port}/page`
26
+
27
+ const { Sidecar } = await import('../sidecar.js')
28
+ const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
29
+
30
+ const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
31
+ /* A short window on purpose: the point is the chain, not the length. */
32
+ const sidecar = new Sidecar({ profile: 'rest-check', headless: true, restFloor: 1200, restCeiling: 1600, budgetMb: 4000 })
33
+ const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
34
+ const tool = (name) => tools.find((one) => one.name === name)
35
+ const cwd = mkdtempSync(join(tmpdir(), 'dsh-rest-cwd-'))
36
+ /* Chats are `session-…` ids: the tab memory on disk only keeps those, and a
37
+ throwaway id would lose the page across a rest instead of testing that it
38
+ is kept. */
39
+ const chat = 'session-rest-check'
40
+ const run = (name, args) => sessionScope.run(chat, () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: chat } })))
41
+ let failed = 0
42
+ const check = (ok, what) => {
43
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${what}`)
44
+ if (!ok) failed += 1
45
+ }
46
+ const text = (said) => String(said?.text ?? '')
47
+ let sidecarDone = false
48
+ try {
49
+ await run('browser_open', { url })
50
+ const before = text(await run('browser_view', {}))
51
+ check(before.includes('this page has to survive a rest'), 'the page opened')
52
+ const upText = text(await run('browser_state', {}))
53
+ check(upText.includes('rests in') || upText.includes('no rest scheduled'), `the state names the rest (${upText.split('\n').filter((row) => row.includes('rest')).join(' / ')})`)
54
+ const up = await sidecar.state()
55
+ check(up.alive === true && up.memory > 0, `the browser is up and costs ${String(up.memory)} bytes`)
56
+
57
+ /* Nobody is watching: this is exactly the state the pane closes into. */
58
+ sidecar.touch(1200)
59
+ const restAt = Date.now() + 1200
60
+ for (let i = 0; i < 60; i += 1) {
61
+ await wait(250)
62
+ const said = await sidecar.state()
63
+ if (said.alive !== true) break
64
+ if (Date.now() > restAt + 15_000) break
65
+ }
66
+ const asleepText = text(await run('browser_state', {}))
67
+ check(asleepText.includes('stopped'), 'the state says the browser is stopped')
68
+ const asleep = await sidecar.state()
69
+ check(asleep.alive !== true, 'the browser went away when its rest ran out')
70
+ check(asleep.memory === 0, 'no memory is held while it rests')
71
+ check(asleep.rests >= 1, `the rest was counted (${String(asleep.rests)})`)
72
+ check(String(asleep.error).includes('put away') || String(asleep.restNote).length > 0, `it said why (${String(asleep.error || asleep.restNote)})`)
73
+
74
+ const asked = Date.now()
75
+ const after = text(await run('browser_view', {}))
76
+ const woke = Date.now() - asked
77
+ const back = await sidecar.state()
78
+ check(back.alive === true, 'the next call brought it back')
79
+ /* The address is the proof: a blank mirror would say `about:blank`, and the
80
+ read is deduplicated against the picture from before the rest. */
81
+ check(sidecar.pageFor(chat)?.url === url, `it came back to the same address (${String(sidecar.pageFor(chat)?.url)})`)
82
+ check(after.includes('this page has to survive a rest') || after.includes('nothing changed since the last read'), 'the page still says what it said before the rest')
83
+ check(woke < 4000, `the wake took ${String(woke)} ms`)
84
+ check(back.wakeMs > 0 && back.wakeMs <= woke + 50, `the sidecar timed its own wake (${String(back.wakeMs)} ms)`)
85
+ check(back.rests >= 1, 'the count survives the wake')
86
+ } finally {
87
+ server.close()
88
+ if (!sidecarDone) await sidecar.shutdown().catch(() => {})
89
+ }
90
+ console.log(failed === 0 ? '\nrest and wake hold.' : `\n${String(failed)} rest checks failed.`)
91
+ process.exitCode = failed === 0 ? 0 : 1
package/scripts/soak.mjs CHANGED
@@ -41,12 +41,15 @@ const docUrl = `http://127.0.0.1:${port}/doc`
41
41
  const { Sidecar } = await import('../sidecar.js')
42
42
  const { sidecarTools, sessionScope, workScope } = await import('../tools.js')
43
43
 
44
- const sidecar = new Sidecar({ profile: 'soak', headless: true })
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 })
45
47
  const tools = sidecarTools(sidecar, { byChat: new Map() }, {})
46
48
  const tool = (name) => tools.find((one) => one.name === name)
47
49
  const cwd = mkdtempSync(join(tmpdir(), 'dsh-soak-cwd-'))
48
50
  /* Same binding the plugin does: the chat and the project a call belongs to. */
49
- const run = (name, args) => sessionScope.run('soak', () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: 'soak' } })))
51
+ const chat = 'session-soak'
52
+ const run = (name, args) => sessionScope.run(chat, () => workScope.run(cwd, () => tool(name).execute(args, { agent: { id: chat } })))
50
53
  const text = (said) => String(said?.text ?? '')
51
54
 
52
55
  /* macOS reports every renderer's RSS in full, shared pages included, so a
@@ -63,11 +66,13 @@ const check = (ok, what) => {
63
66
  const startedAt = Date.now()
64
67
  try {
65
68
  await sidecar.launch('about:blank')
66
- await sidecar.watch(() => {})
67
- await sidecar.ensure({ session: 'soak' })
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 })
68
72
 
69
73
  let reads = 0
70
74
  let snapshots = 0
75
+ let rests = 0
71
76
  for (let i = 0; i < cycles; i += 1) {
72
77
  const url = i % 2 === 0 ? appUrl : docUrl
73
78
  await run('browser_open', { url })
@@ -86,6 +91,19 @@ try {
86
91
  samples.push({ cycle: i + 1, mb, pages: Number(state?.pages ?? 0) })
87
92
  console.log(` cycle ${String(i + 1).padStart(3)} · ${String(mb)} MB · pages ${String(state?.pages)}`)
88
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
+ }
89
107
  /* A quiet moment, so a leak in the close/reopen path is not hidden by the
90
108
  next navigation arriving before the old page is gone. */
91
109
  if (i % 6 === 5) await new Promise((settle) => setTimeout(settle, 250))
@@ -99,7 +117,9 @@ try {
99
117
  check(samples.every((one) => one.pages <= 1), `one page per chat is kept (max ${String(Math.max(...samples.map((one) => one.pages)))})`)
100
118
  check(last.mb < ceilingMb, `RSS stays inside this machine's ceiling (${String(last.mb)} < ${String(ceilingMb)} MB)`)
101
119
  check(growth < 250, `RSS growth over ${String(cycles)} cycles is bounded (${growth >= 0 ? '+' : ''}${String(growth)} MB)`)
102
- 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`)
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`)
103
123
  } catch (error) {
104
124
  failed += 1
105
125
  console.log('FAIL ' + String(error?.stack ?? error))