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 +10 -2
- package/README.zh.md +9 -2
- package/docs/ARCHITECTURE.md +24 -3
- package/docs/BENCH.md +20 -0
- package/docs/RESOURCES.md +83 -0
- package/docs/VERIFY.md +25 -5
- package/package.json +6 -3
- package/rest.js +124 -0
- package/scripts/check-rest.mjs +91 -0
- package/scripts/soak.mjs +25 -5
- package/sidecar.js +303 -20
- package/tools.js +14 -2
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
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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
|
-
|
|
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))
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
3136
|
-
|
|
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
|
-
|
|
3141
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|