dsh-inner-browser-use 0.45.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 CHANGED
@@ -76,8 +76,16 @@ sign-in here is still there the next day.
76
76
  - **Harness restart.** The same directory is opened again, so cookies, `localStorage` and
77
77
  passwords that Chrome saved all survive.
78
78
  - **Shutdown.** The browser is asked to close rather than killed, which is when Chrome writes
79
- the last of its cookies; an idle browser is closed after 15 minutes, and the clock stops
80
- while a column is watching.
79
+ the last of its cookies.
80
+
81
+ ## When nobody is looking
82
+
83
+ A browser used every two seconds rests after two minutes; one used every ninety seconds waits
84
+ nine. The window is p95 of the person's own last 64 gaps, × 6 for rest and × 2 for a reversible
85
+ pause, so it is learned rather than configured. Close the column and the rest is one minute
86
+ away; leave it open and the page is frozen instead (DOM, scroll and sign-ins kept, screenshot
87
+ stream stopped). A wake is 0.3–1.1 s and lands on the same address. `npm run rest` checks the
88
+ whole chain headlessly; `docs/RESOURCES.md` has the memory ladder and how the neighbours do it.
81
89
 
82
90
  Screenshots are the only thing written outside the profile:
83
91
  `~/.dsh-inner-browser-use/screenshots/`.
package/README.zh.md CHANGED
@@ -68,8 +68,15 @@ profile 是独立目录:`~/.dsh/inner-browser-use/chrome/default`。它在产
68
68
 
69
69
  - **插件重载。** 重载会接上仍在运行的那个 Chrome,页面也还在原处。
70
70
  - **Harness 重启。** 重新打开同一个目录,cookie、`localStorage` 和 Chrome 存下的密码都留着。
71
- - **退出。** 关浏览器是请它自己关(`Browser.close`),Chrome 在这一步才把 cookie 落盘;空闲
72
- 15 分钟会关掉,有右栏在看时不计这段空闲。
71
+ - **退出。** 关浏览器是请它自己关(`Browser.close`),Chrome 在这一步才把 cookie 落盘。
72
+
73
+ ## 没人看的时候
74
+
75
+ 每两秒用一次的人,两分钟后就休息;每九十秒用一次的人能等九分钟——窗口取自这个人最近 64 次
76
+ 调用间隔的 95 分位,休息乘 6、可逆停驻乘 2,是学出来的不是配出来的。右栏关掉后一分钟就真的
77
+ 释放(内存归零,cookies 先存好);右栏还开着只是没人动,就走停驻——DOM、滚动位置、登录态都
78
+ 留着,截图流停掉。唤醒 0.3–1.1 秒,回到同一个地址。无头验证跑 `npm run rest`,内存阶梯与
79
+ 竞品对照在 `docs/RESOURCES.md`。
73
80
 
74
81
  profile 之外只写一样东西:`~/.dsh-inner-browser-use/screenshots/` 里的截图。
75
82
 
@@ -61,9 +61,30 @@ One page per browser. `apply()` only constructs the `Sidecar`; no process exists
61
61
  first tool call, and `browser_state` reports a stopped browser without starting one.
62
62
 
63
63
  Shutdown asks Chrome to close (`Browser.close`), waits briefly, then sends `SIGTERM`, which is
64
- the point where Chrome flushes cookies. An idle timer closes the browser after 15 minutes; a
65
- tool call resets the clock, and a watching column defers the close when the timer fires, so a
66
- page someone is reading outlives the idle window.
64
+ the point where Chrome flushes cookies.
65
+
66
+ Lifetime is a ladder rather than one timer, because a browser that is being watched and one
67
+ that was forgotten deserve different answers (`docs/RESOURCES.md` has the measurements and the
68
+ comparison with other projects):
69
+
70
+ | step | trigger | effect |
71
+ | --- | --- | --- |
72
+ | awake | a tool call, or a mirror watching | none |
73
+ | quiet | no call for `quietAfter()` and a mirror is still open | screencast stopped, page frozen |
74
+ | rest | no call for `restAfter()` and nothing watching | cookies saved, whole tree closed |
75
+
76
+ `rest.js` derives both windows from the gap between the person's own calls: p95 of the last 64
77
+ gaps × 6 for rest, × 2 for quiet, each clamped (`90 s – 15 min`, `20 s – 2 min`). A fresh
78
+ process has no history and uses the floors. `decide()` is pure, so the clock is testable
79
+ without a browser, and the watched count always beats the deadline: a page someone can see is
80
+ frozen at most, never closed.
81
+
82
+ Closing is single-flight (`shutdown()` returns the promise already in flight) and `launch()`
83
+ waits for the outgoing browser before spawning the next one — without that, a rest that fires
84
+ as a call arrives closes the socket the call is about to use. Every 30 s the tree is weighed
85
+ against `DSH_INNER_BROWSER_MEMORY_MB` (default 2400): over budget, idle tabs are reclaimed by
86
+ LRU and casts back off; over again with nobody watching, the browser rests on the spot. Three
87
+ crashes inside ten minutes stop the restart loop and say why instead of trying forever.
67
88
 
68
89
  ## Routes
69
90
 
package/docs/BENCH.md CHANGED
@@ -1030,3 +1030,23 @@ not.
1030
1030
  | frame size, JPEG q72 | 96–103 KB |
1031
1031
  | mirror SSE | 15 fps(66 ms 一轮),4 s 收 60 帧,均值 97.8 KB |
1032
1032
  | switch cost | 一次浏览器重启(`browser_open({flash:true})` → `browser_open({flash:false})`) |
1033
+
1034
+ ## Phase 24 — 长期运行的资源回收,0.46.0
1035
+
1036
+ 一台窗口隐藏的 Chrome 空转一晚就是几百 MB。这一版把「多久收」交给用法自己决定:记住这个人
1037
+ 最近 64 次调用之间的间隔,取 95 分位,休息乘 6、可逆停驻乘 2,各自夹在上下限里;有人看时
1038
+ 永不关闭、只冻结。关闭与启动串行,避免休息和下一次调用撞在一起。
1039
+
1040
+ | what | measured |
1041
+ | --- | --- |
1042
+ | 20 次调用、间隔 20 秒 | p95 20 s → 休息 120 s,停驻 40 s |
1043
+ | 30 次调用、间隔 90 秒 | p95 90 s → 休息 540 s,停驻 180 s |
1044
+ | 没有历史的新进程 | 用下限:休息 90 s,停驻 20 s |
1045
+ | 右栏关掉(`/sidecar/forget`) | 60 s 后休息,内存归零 |
1046
+ | 唤醒 | 326–1010 ms,回到同一页同一地址 |
1047
+ | soak 24 轮+3 次真实休息 | 40.1 s,内存 1427 → 956 MB,页数始终 1 |
1048
+ | soak 16 轮+2 次真实休息 | 26.8 s,内存 1308 → 900 MB |
1049
+ | 休息与调用相撞 | 关闭单飞、启动等待;0.45.0 会在第 8 轮把新连接关掉(已复现、已修) |
1050
+ | 内存预算 | 2400 MB / 30 s 一次称重;超预算先收 LRU 页签、降码率,无人看才休息 |
1051
+ | 崩溃循环 | 10 分钟内 3 次 → 拒绝启动并说明原因 |
1052
+ | suite | 42 pass(sidecar)· rest 9 pass · soak/rest 端到端全过 |
@@ -0,0 +1,83 @@
1
+ # Long-term resources: what keeps a browser cheap when nobody is looking
2
+
3
+ A sidecar is only worth keeping if leaving it open overnight costs nothing. This
4
+ document describes the ladder a browser steps down when it goes unused, how the
5
+ timings are chosen, and how the same problem is solved by the four projects a
6
+ person is most likely to compare this plugin to.
7
+
8
+ ## The ladder
9
+
10
+ | step | when | what happens | cost while parked |
11
+ | --- | --- | --- | --- |
12
+ | `awake` | calls are arriving, or someone is watching the mirror | nothing | one headless Chrome |
13
+ | `quiet` | no call for a while, but a pane still shows the page | screencast stopped, page frozen (`Page.setWebLifecycleState`) | JS/timers paused, DOM and cookies kept, still one process tree |
14
+ | `rest` | no call for a while and no pane watching | cookies saved, `Browser.close`, whole tree gone | zero |
15
+
16
+ The rest step is what the pane closing at `x` earns: the plugin tells the
17
+ sidecar the mirror is gone, and if nobody else is watching it arms a one-minute
18
+ rest. A chat that never closed its pane still gets the quiet step, which is
19
+ reversible and keeps the page exactly where the person left it.
20
+
21
+ ## Choosing the timings without guessing
22
+
23
+ Fixed timeouts are wrong in both directions: too short and every pause pays a
24
+ cold start, too long and an abandoned browser outlives the work. The rest and
25
+ quiet windows are therefore derived from what the user actually did in this
26
+ process. `rest.js` keeps the last 64 gaps between calls, takes the 95th
27
+ percentile, and multiplies it:
28
+
29
+ ```text
30
+ rest = clamp(p95(gaps) × 6, 90 s, 15 min)
31
+ quiet = clamp(p95(gaps) × 2, 20 s, 2 min)
32
+ ```
33
+
34
+ A person clicking every two seconds gets a two-minute rest; one coming back
35
+ every ninety seconds gets nine minutes. Gaps longer than the maximum rest are
36
+ treated as a new session, not as evidence. With no history at all the floors
37
+ apply, so a fresh process parks quickly instead of waiting for a pattern that
38
+ does not exist yet.
39
+
40
+ The decision itself is a pure function of four numbers — now, last use, the
41
+ rest deadline, and how many mirrors are watching — which is what makes it
42
+ testable without a browser (`test/rest.test.mjs`). The watched count wins over
43
+ the deadline: a pane on screen always means `quiet` at most, never `rest`.
44
+
45
+ ## Memory pressure
46
+
47
+ Timings are the first line; memory is the second. Every 30 s the sidecar weighs
48
+ the whole process tree and compares it with a budget (2400 MB by default,
49
+ `DSH_INNER_BROWSER_MEMORY_MB`). Over budget while a pane is open, it stops
50
+ screencasts and reclaims unused tabs (LRU, pinned pages excluded). Over budget
51
+ again with nobody watching, it puts the browser away on the spot rather than
52
+ waiting for the clock. Being over once is not enough to close a browser the
53
+ person is looking at.
54
+
55
+ ## What breaks a long run, and what is done about it
56
+
57
+ | failure | response |
58
+ | --- | --- |
59
+ | a rest and the next call arrive together | `shutdown()` is single-flight and `launch()` awaits the outgoing browser, so a fresh socket is never closed by the one leaving |
60
+ | the browser dies three times in ten minutes | the crash-loop guard refuses to start it again and says why, instead of spinning |
61
+ | a tab is left in a chat that stopped using it | 10-minute LRU, skipped while it streams or holds a popup |
62
+ | the last call was minutes ago but the pane is open | freeze, not close: scroll position, DOM and sign-ins are still there on wake |
63
+
64
+ ## Neighbours
65
+
66
+ Compared with the projects this one is usually put next to (`--idle-timeout`,
67
+ `keep_alive`, and their documentation):
68
+
69
+ | project | idle handling | what a wake costs | notes |
70
+ | --- | --- | --- | --- |
71
+ | Playwright MCP | one flag, `--idle-timeout`, default one hour for headless, whole browser closed | a cold start | no viewer awareness; each client gets its own Chrome; `src/` is no longer in the repository |
72
+ | Chrome DevTools MCP | none — the browser lives until the process exits | n/a | one dispose chain, no idle clock, screencast never pauses for a detached viewer |
73
+ | browser-use | `keep_alive` flag, no timeout value | n/a | teardown skipped while `keep_alive`; no idle timer, no memory policy |
74
+ | Stagehand | none | n/a | deliberately disables Chrome's background throttling, then tears down and removes the profile |
75
+ | this plugin | viewer-aware ladder, timings learned from use, memory pressure, single-flight close | 0.3–1.1 s, same page and address | freeze keeps the DOM; rest keeps the cookies; nothing cold-starts unless the process is new |
76
+
77
+ The gaps that remain in the field, and that this document treats as the design
78
+ brief: nobody freezes a page instead of closing it; nobody pauses a screencast
79
+ because the viewer looked away; cold restart is the documented recovery
80
+ everywhere; tab caps and LRU are left to the caller; memory is a diagnostic
81
+ rather than a policy; multi-agent isolation is all-or-nothing. Those are the
82
+ five places where this plugin is deliberately ahead rather than equal — see
83
+ `docs/ARCHITECTURE.md` and `docs/BENCH.md` for the measured side of it.
package/docs/VERIFY.md CHANGED
@@ -132,11 +132,31 @@ than the page's: a cut must not shrink the site for the next visit.
132
132
  npm run soak
133
133
  ```
134
134
 
135
- Twenty-four cycles of open, read, snapshot, re-read through one headless browser.
136
- Expected: `soak: ok · 24 cycles` with `pages 1` and memory lower at the end than
137
- at the start. Memory climbing with the cycle count, or a second page appearing,
138
- means something is kept per read rather than per page. The run is bounded on
139
- purpose — under a minute, no internet.
135
+ Twenty-four cycles of open, read, snapshot, re-read through one headless browser,
136
+ and every eighth cycle the browser is really put away and woken again.
137
+ Expected: `soak: ok · 24 cycles` with `pages 1`, memory lower at the end than at
138
+ the start, and a summary naming at least one rest (`3 rests` here, 1427 → 956 MB
139
+ in 40.1 s). Memory climbing with the cycle count, a second page appearing, or a
140
+ rest that leaves memory held means something is kept per read rather than per
141
+ page, or the rest does not close the whole tree. The run is bounded on purpose —
142
+ under a minute, no internet.
143
+
144
+ ## 10. It comes back where it was
145
+
146
+ ```bash
147
+ npm run rest
148
+ ```
149
+
150
+ A headless sidecar is told to rest 1.2 s after the last call. Expected chain:
151
+ `browser_state` says `rests in`, then `stopped` with `memory 0 MB` once the rest
152
+ lands; touching the browser again wakes it on its own, `pageFor(session).url` is
153
+ still the page that was open, the text is either the same paragraph or
154
+ `nothing changed since the last read`, and the wake takes under 4 s. `rest and
155
+ wake hold.` is the bar.
156
+
157
+ A rest that never happens while nobody is watching means the watched count is
158
+ not reaching the clock; a wake that lands on a blank tab means the page-per-chat
159
+ map was dropped by the close instead of being restored by the launch.
140
160
 
141
161
  ## Live
142
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "index.js",
16
16
  "sidecar.js",
17
+ "rest.js",
17
18
  "tools.js",
18
19
  "read.js",
19
20
  "review.js",
@@ -33,6 +34,7 @@
33
34
  "scripts/check-look.mjs",
34
35
  "scripts/check-sites.mjs",
35
36
  "scripts/soak.mjs",
37
+ "scripts/check-rest.mjs",
36
38
  "runtime/",
37
39
  "docs/",
38
40
  "README.md",
@@ -55,7 +57,7 @@
55
57
  "dsh": ">=0.2.0"
56
58
  },
57
59
  "scripts": {
58
- "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/blocks.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
60
+ "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/blocks.test.mjs test/rest.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
59
61
  "doctor": "node scripts/doctor.mjs",
60
62
  "breadth": "node scripts/breadth.mjs",
61
63
  "live": "node scripts/live.mjs",
@@ -72,7 +74,8 @@
72
74
  "keys": "node scripts/keyboard.mjs",
73
75
  "look": "node scripts/check-look.mjs",
74
76
  "sites": "node scripts/check-sites.mjs",
75
- "soak": "node scripts/soak.mjs"
77
+ "soak": "node scripts/soak.mjs",
78
+ "rest": "node scripts/check-rest.mjs"
76
79
  },
77
80
  "license": "MIT",
78
81
  "author": {
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,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))
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))
@@ -1484,6 +1497,35 @@ export class SidecarPage {
1484
1497
  }
1485
1498
  }
1486
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
+
1487
1529
  async stopCast() {
1488
1530
  if (!this.casting) return
1489
1531
  this.casting = false
@@ -2149,6 +2191,36 @@ export class Sidecar {
2149
2191
  enough that a stray click does not stall a run, long enough that the agent
2150
2192
  does not type into a field the person is in the middle of filling. */
2151
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
2152
2224
  }
2153
2225
 
2154
2226
  /**
@@ -2413,15 +2485,19 @@ export class Sidecar {
2413
2485
  * chats actually in use, which is what the memory number follows.
2414
2486
  * @returns {Promise<void>} done.
2415
2487
  */
2416
- 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) {
2417
2493
  const now = Date.now()
2418
- if (now - Number(this.reapedAt ?? 0) < 60_000) return
2494
+ if (now - Number(this.reapedAt ?? 0) < 60_000 && all !== true) return
2419
2495
  this.reapedAt = now
2420
2496
  for (const [id, page] of this.pages) {
2421
2497
  const target = String(page.targetId ?? '')
2422
2498
  if ((page.frames?.size ?? 0) !== 0 || target === '' || page.port === 0) continue
2423
2499
  if (page === this.page) continue
2424
- if (now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
2500
+ if (all !== true && now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
2425
2501
  this.pages.delete(id)
2426
2502
  /* The column is told the tab is gone, so a chat coming back to the front
2427
2503
  does not re-expand a pane for a page that already went away. */
@@ -2520,6 +2596,9 @@ export class Sidecar {
2520
2596
  if (id === '') return page
2521
2597
  const mine = this.pageFor(id)
2522
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()
2523
2602
  /* A chat's tab can die on its own: a crash, a browser another call
2524
2603
  restarted, a tab closed by hand. Handing that corpse back leaves the
2525
2604
  mirror on a blank picture forever, so it has to answer before it is
@@ -2549,6 +2628,26 @@ export class Sidecar {
2549
2628
  return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
2550
2629
  }
2551
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
+
2552
2651
  async ensurePage(options = {}) {
2553
2652
  if (options.profile !== undefined) this.profile = options.profile
2554
2653
  const wanted = options.headless === undefined ? this.headless : options.headless !== false
@@ -2598,6 +2697,7 @@ export class Sidecar {
2598
2697
  try {
2599
2698
  return await this.recover(wasAt)
2600
2699
  } catch {
2700
+ if (this.died()) throw this.tooManyCrashes()
2601
2701
  await this.shutdown()
2602
2702
  return this.ensure({ ...options, url: wasAt })
2603
2703
  }
@@ -2610,6 +2710,7 @@ export class Sidecar {
2610
2710
  try {
2611
2711
  return await this.recover(page.lastUrl || this.lastUrl)
2612
2712
  } catch {
2713
+ if (this.died()) throw this.tooManyCrashes()
2613
2714
  await this.shutdown()
2614
2715
  return this.ensure({ ...options, url: page.lastUrl || this.lastUrl })
2615
2716
  }
@@ -2638,16 +2739,27 @@ export class Sidecar {
2638
2739
  this.note('renderer stopped answering; opening a fresh tab')
2639
2740
  this.starting = this.recover(wasAt).catch((error) => {
2640
2741
  this.note(String(error?.message ?? error))
2742
+ if (this.died()) throw this.tooManyCrashes()
2641
2743
  return this.launch(wasAt)
2642
2744
  }).finally(() => {
2643
2745
  this.starting = null
2644
2746
  })
2645
2747
  return this.starting
2646
2748
  }
2749
+ const asked = Date.now()
2647
2750
  this.starting = this.launch(options.url).finally(() => {
2648
2751
  this.starting = null
2649
2752
  })
2650
- 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
+ })
2651
2763
  }
2652
2764
 
2653
2765
  /**
@@ -2675,6 +2787,9 @@ export class Sidecar {
2675
2787
 
2676
2788
  /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
2677
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(() => {})
2678
2793
  /* Chromium 87 opening the profile a current Chrome owns would downgrade it
2679
2794
  and lock it, so the Flash-era browser keeps a profile of its own. The two
2680
2795
  never share a directory; a game signed into one is signed in there only. */
@@ -3129,27 +3244,153 @@ export class Sidecar {
3129
3244
  }
3130
3245
 
3131
3246
  /**
3132
- * Starts the idle clock again.
3133
- * @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.
3134
3252
  */
3135
- touch(ms = IDLE_MS) {
3136
- 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() {
3137
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. */
3138
3274
  this.idle = setTimeout(() => {
3139
3275
  this.idle = null
3140
- /* Someone is watching the mirror: the page stays up, and the clock starts
3141
- again so the browser is closed only once it is truly unused. */
3142
- let watched = this.page?.frames.size ?? 0
3143
- for (const page of this.pages.values()) watched += page.frames?.size ?? 0
3144
- if (watched > 0) {
3145
- this.touch()
3146
- return
3147
- }
3148
- void this.shutdown()
3149
- }, ms)
3276
+ void this.beatNow().catch(() => {})
3277
+ }, Math.max(0, Number(this.restAt) - Date.now()))
3150
3278
  this.idle.unref?.()
3151
3279
  }
3152
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
+
3153
3394
  /** @returns {Promise<void>} resolves once Chrome is gone. */
3154
3395
  /**
3155
3396
  * Leaves the browser running for whoever loads this plugin next, and puts the
@@ -3161,6 +3402,7 @@ export class Sidecar {
3161
3402
  */
3162
3403
  async handoff() {
3163
3404
  this.stopping = true
3405
+ this.sleepClock()
3164
3406
  if (this.page !== null) await this.saveSessionCookies()
3165
3407
  if (this.backup !== undefined) clearInterval(this.backup)
3166
3408
  this.backup = undefined
@@ -3225,7 +3467,25 @@ export class Sidecar {
3225
3467
  }
3226
3468
 
3227
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() {
3228
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()
3229
3489
  /* A browser this process did not start belongs to somebody else — another
3230
3490
  app on the same profile, or a script that connected to it — and closing
3231
3491
  it would close the person's window. Let go of it instead: drop the socket
@@ -3391,9 +3651,32 @@ export class Sidecar {
3391
3651
  /* What this browser costs right now: the whole process tree, renderers
3392
3652
  included. Reported so a chat can decide whether to let it rest. */
3393
3653
  memory: this.alive ? treeBytes(Number(this.child?.pid ?? 0)) : 0,
3394
- /* 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
3395
3655
  browser is either watched or not on a clock. */
3396
- 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,
3397
3680
  error: this.alive ? '' : this.lastError,
3398
3681
  }
3399
3682
  }
package/tools.js CHANGED
@@ -3548,11 +3548,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
3548
3548
  /* What it costs and when it would let go: the two facts that decide
3549
3549
  whether to keep using it or let it rest. */
3550
3550
  state.alive === true ? `memory ${Math.max(1, Math.round(Number(state.memory ?? 0) / 1048576))} MB` : 'memory 0 MB',
3551
- state.closesIn > 0 ? `closes in ${Math.max(1, Math.round(Number(state.closesIn) / 60_000))} min unless used` : 'no close scheduled',
3551
+ /* The rest is what a chat needs to know about: whether the browser is
3552
+ about to go away, is parked with nobody watching, or is gone and
3553
+ will open again on the next call. */
3554
+ state.alive !== true
3555
+ ? Number(state.wakeMs ?? 0) > 0
3556
+ ? `stopped — the next call opens it (${String(Math.round(Number(state.wakeMs)))} ms last time)`
3557
+ : 'stopped — the next call opens it'
3558
+ : state.resting === true
3559
+ ? 'parked while no pane watches'
3560
+ : state.restIn > 0
3561
+ ? `rests in ${String(Math.max(1, Math.round(Number(state.restIn) / 60_000)))} min unless used`
3562
+ : 'no rest scheduled',
3563
+ (state.parked ?? 0) > 0 ? `parked pages ${String(state.parked)}` : '',
3552
3564
  holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
3553
3565
  ]
3554
3566
  if (state.alive === true && viewerNote(state.viewers) !== '') rows.push(NO_VIEWER)
3555
- return { text: rows.join('\n') }
3567
+ return { text: rows.filter((row) => row !== '').join('\n') }
3556
3568
  },
3557
3569
  },
3558
3570