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/README.md +17 -2
- package/README.zh.md +16 -2
- package/docs/ARCHITECTURE.md +24 -3
- package/docs/BENCH.md +71 -0
- package/docs/MATURITY.md +52 -0
- package/docs/RESOURCES.md +92 -0
- package/docs/VERIFY.md +43 -5
- package/docs/measure-competitors-2026-11.json +523 -0
- package/package.json +8 -3
- package/rest.js +124 -0
- package/scripts/bench-resource.mjs +173 -0
- package/scripts/check-rest.mjs +91 -0
- package/scripts/soak.mjs +25 -5
- package/sidecar.js +352 -21
- package/tools.js +14 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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))
|