dsh-inner-browser-use 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/README.zh.md +16 -2
- package/docs/ARCHITECTURE.md +24 -3
- package/docs/BENCH.md +71 -0
- package/docs/MATURITY.md +52 -0
- package/docs/RESOURCES.md +92 -0
- package/docs/VERIFY.md +43 -5
- package/docs/measure-competitors-2026-11.json +523 -0
- package/package.json +8 -3
- package/rest.js +124 -0
- package/scripts/bench-resource.mjs +173 -0
- package/scripts/check-rest.mjs +91 -0
- package/scripts/soak.mjs +25 -5
- package/sidecar.js +352 -21
- package/tools.js +14 -2
package/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,52 @@ 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
|
+
/**
|
|
1508
|
+
* What a parked page gives back: the heap it grew while it was being read,
|
|
1509
|
+
* and the decoded images and back/forward caches the renderer keeps for a
|
|
1510
|
+
* page a person may look at again. Frozen pages cannot run the collector, so
|
|
1511
|
+
* this happens before the freeze — the DOM, the scroll position and the
|
|
1512
|
+
* cookies are untouched, which is the whole point of parking instead of
|
|
1513
|
+
* closing.
|
|
1514
|
+
* @returns {Promise<void>} done, even on a renderer that refuses.
|
|
1515
|
+
*/
|
|
1516
|
+
async shed() {
|
|
1517
|
+
await this.cdp.send('Runtime.evaluate', {
|
|
1518
|
+
expression: 'globalThis.gc?.()', silent: true, returnByValue: true,
|
|
1519
|
+
}).catch(() => {})
|
|
1520
|
+
await this.cdp.send('Memory.simulatePressureNotification', { level: 'critical' }).catch(() => {})
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async freeze() {
|
|
1524
|
+
if (this.frozen === true) return
|
|
1525
|
+
try {
|
|
1526
|
+
await this.shed()
|
|
1527
|
+
await this.cdp.send('Page.setWebLifecycleState', { state: 'frozen' })
|
|
1528
|
+
this.frozen = true
|
|
1529
|
+
this.casting = false
|
|
1530
|
+
} catch {
|
|
1531
|
+
/* A page between navigations refuses; it is not a reason to fail a call. */
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** @returns {Promise<void>} done, with the page running again. */
|
|
1536
|
+
async thaw() {
|
|
1537
|
+
if (this.frozen !== true) return
|
|
1538
|
+
this.frozen = false
|
|
1539
|
+
try {
|
|
1540
|
+
await this.cdp.send('Page.setWebLifecycleState', { state: 'active' })
|
|
1541
|
+
} catch {
|
|
1542
|
+
/* Already gone: the next call finds a dead page and replaces it. */
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1487
1546
|
async stopCast() {
|
|
1488
1547
|
if (!this.casting) return
|
|
1489
1548
|
this.casting = false
|
|
@@ -2149,6 +2208,36 @@ export class Sidecar {
|
|
|
2149
2208
|
enough that a stray click does not stall a run, long enough that the agent
|
|
2150
2209
|
does not type into a field the person is in the middle of filling. */
|
|
2151
2210
|
this.holdMs = 6000
|
|
2211
|
+
/* When the browser was last asked for anything, and the rhythm of those
|
|
2212
|
+
asks: the two numbers that decide when a pause has become a departure. */
|
|
2213
|
+
this.use = new GapClock()
|
|
2214
|
+
this.useAt = 0
|
|
2215
|
+
/* The moment this browser may be put down, or 0 while it has no clock. */
|
|
2216
|
+
this.restAt = 0
|
|
2217
|
+
/* The lifecycle beat, and what it last found. */
|
|
2218
|
+
this.beat = null
|
|
2219
|
+
this.resting = false
|
|
2220
|
+
this.memMb = 0
|
|
2221
|
+
this.memAt = 0
|
|
2222
|
+
this.pressure = 'ok'
|
|
2223
|
+
this.budgetMb = Math.max(200, Number(options.budgetMb ?? BUDGET_MB) || BUDGET_MB)
|
|
2224
|
+
/* A window this browser is never given less than, or more than: a test can
|
|
2225
|
+
shrink both, and a chat that works in fast steps still gets grace. */
|
|
2226
|
+
this.restFloor = Math.max(1000, Number(options.restFloor ?? MIN_REST_MS) || MIN_REST_MS)
|
|
2227
|
+
this.restCeiling = Math.max(this.restFloor, Number(options.restCeiling ?? MAX_REST_MS) || MAX_REST_MS)
|
|
2228
|
+
/* How often it has been put down and brought back, and how long the last
|
|
2229
|
+
wake took: a rest that costs more than it saves is a bug worth seeing. */
|
|
2230
|
+
this.rests = 0
|
|
2231
|
+
this.sleptAt = 0
|
|
2232
|
+
this.wakeMs = 0
|
|
2233
|
+
/* Restarts inside a window: three in ten minutes is a browser that is
|
|
2234
|
+
broken, not a browser that was needed. */
|
|
2235
|
+
this.crashes = []
|
|
2236
|
+
this.restartWindow = Math.max(10_000, Number(options.restartWindow ?? 10 * 60_000) || 10 * 60_000)
|
|
2237
|
+
/* The shutdown in flight, if any: a launch that starts while the last
|
|
2238
|
+
browser is still being put away would have its fresh socket closed by
|
|
2239
|
+
the one going out. */
|
|
2240
|
+
this.closing = null
|
|
2152
2241
|
}
|
|
2153
2242
|
|
|
2154
2243
|
/**
|
|
@@ -2309,6 +2398,10 @@ export class Sidecar {
|
|
|
2309
2398
|
this.expired?.delete(id)
|
|
2310
2399
|
const had = this.pageFor(id)
|
|
2311
2400
|
if (had !== null) {
|
|
2401
|
+
/* A parked page is still the chat's page: waking it costs a protocol call,
|
|
2402
|
+
and a tool that read without waking it would see a page that never runs
|
|
2403
|
+
its scripts again. */
|
|
2404
|
+
await had.thaw?.()
|
|
2312
2405
|
if (url !== undefined && url !== '') await had.navigate(url)
|
|
2313
2406
|
return had
|
|
2314
2407
|
}
|
|
@@ -2317,6 +2410,7 @@ export class Sidecar {
|
|
|
2317
2410
|
const underway = this.opening?.get(id)
|
|
2318
2411
|
if (underway !== undefined) {
|
|
2319
2412
|
const page = await underway
|
|
2413
|
+
await page.thaw?.()
|
|
2320
2414
|
const want = String(url ?? '')
|
|
2321
2415
|
if (want !== '' && String(page.url ?? '') !== want) await page.navigate(want)
|
|
2322
2416
|
return page
|
|
@@ -2413,15 +2507,19 @@ export class Sidecar {
|
|
|
2413
2507
|
* chats actually in use, which is what the memory number follows.
|
|
2414
2508
|
* @returns {Promise<void>} done.
|
|
2415
2509
|
*/
|
|
2416
|
-
|
|
2510
|
+
/**
|
|
2511
|
+
* Closes tabs nobody watches, keeping their address for the chat.
|
|
2512
|
+
* @param {boolean} [all] - close them whatever their age, for memory pressure.
|
|
2513
|
+
*/
|
|
2514
|
+
async reapIdleTabs(all = false) {
|
|
2417
2515
|
const now = Date.now()
|
|
2418
|
-
if (now - Number(this.reapedAt ?? 0) < 60_000) return
|
|
2516
|
+
if (now - Number(this.reapedAt ?? 0) < 60_000 && all !== true) return
|
|
2419
2517
|
this.reapedAt = now
|
|
2420
2518
|
for (const [id, page] of this.pages) {
|
|
2421
2519
|
const target = String(page.targetId ?? '')
|
|
2422
2520
|
if ((page.frames?.size ?? 0) !== 0 || target === '' || page.port === 0) continue
|
|
2423
2521
|
if (page === this.page) continue
|
|
2424
|
-
if (now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
|
|
2522
|
+
if (all !== true && now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
|
|
2425
2523
|
this.pages.delete(id)
|
|
2426
2524
|
/* The column is told the tab is gone, so a chat coming back to the front
|
|
2427
2525
|
does not re-expand a pane for a page that already went away. */
|
|
@@ -2465,6 +2563,10 @@ export class Sidecar {
|
|
|
2465
2563
|
this.expired?.delete(String(id))
|
|
2466
2564
|
const had = this.pageFor(id)
|
|
2467
2565
|
if (had !== null) {
|
|
2566
|
+
/* A parked page is still the chat's page: waking it costs a protocol call,
|
|
2567
|
+
and a tool that read without waking it would see a page that never runs
|
|
2568
|
+
its scripts again. */
|
|
2569
|
+
await had.thaw?.()
|
|
2468
2570
|
if (url !== undefined && url !== '') await had.navigate(url)
|
|
2469
2571
|
return had
|
|
2470
2572
|
}
|
|
@@ -2520,6 +2622,9 @@ export class Sidecar {
|
|
|
2520
2622
|
if (id === '') return page
|
|
2521
2623
|
const mine = this.pageFor(id)
|
|
2522
2624
|
if (mine !== null) {
|
|
2625
|
+
/* A page parked while nobody watched has to be running before it can be
|
|
2626
|
+
driven; this is the whole point of freezing rather than closing it. */
|
|
2627
|
+
if (mine.frozen === true) await mine.thaw()
|
|
2523
2628
|
/* A chat's tab can die on its own: a crash, a browser another call
|
|
2524
2629
|
restarted, a tab closed by hand. Handing that corpse back leaves the
|
|
2525
2630
|
mirror on a blank picture forever, so it has to answer before it is
|
|
@@ -2549,6 +2654,26 @@ export class Sidecar {
|
|
|
2549
2654
|
return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
|
|
2550
2655
|
}
|
|
2551
2656
|
|
|
2657
|
+
/**
|
|
2658
|
+
* Records a browser that stopped working, and reports whether it has stopped
|
|
2659
|
+
* working so often that starting it again would only produce another crash
|
|
2660
|
+
* on the person's screen. Three in ten minutes is a broken browser, not a
|
|
2661
|
+
* browser the chat needed; saying so once is more useful than a loop.
|
|
2662
|
+
* @returns {boolean} true when it is time to stop trying automatically.
|
|
2663
|
+
*/
|
|
2664
|
+
died() {
|
|
2665
|
+
const now = Date.now()
|
|
2666
|
+
this.crashes = [...this.crashes.filter((at) => now - Number(at) < this.restartWindow), now]
|
|
2667
|
+
return this.crashes.length > 3
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
/** @returns {Error} the error a chat sees instead of a fourth doomed launch. */
|
|
2671
|
+
tooManyCrashes() {
|
|
2672
|
+
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')
|
|
2673
|
+
this.note(error.message)
|
|
2674
|
+
return error
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2552
2677
|
async ensurePage(options = {}) {
|
|
2553
2678
|
if (options.profile !== undefined) this.profile = options.profile
|
|
2554
2679
|
const wanted = options.headless === undefined ? this.headless : options.headless !== false
|
|
@@ -2598,6 +2723,7 @@ export class Sidecar {
|
|
|
2598
2723
|
try {
|
|
2599
2724
|
return await this.recover(wasAt)
|
|
2600
2725
|
} catch {
|
|
2726
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2601
2727
|
await this.shutdown()
|
|
2602
2728
|
return this.ensure({ ...options, url: wasAt })
|
|
2603
2729
|
}
|
|
@@ -2610,6 +2736,7 @@ export class Sidecar {
|
|
|
2610
2736
|
try {
|
|
2611
2737
|
return await this.recover(page.lastUrl || this.lastUrl)
|
|
2612
2738
|
} catch {
|
|
2739
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2613
2740
|
await this.shutdown()
|
|
2614
2741
|
return this.ensure({ ...options, url: page.lastUrl || this.lastUrl })
|
|
2615
2742
|
}
|
|
@@ -2638,16 +2765,27 @@ export class Sidecar {
|
|
|
2638
2765
|
this.note('renderer stopped answering; opening a fresh tab')
|
|
2639
2766
|
this.starting = this.recover(wasAt).catch((error) => {
|
|
2640
2767
|
this.note(String(error?.message ?? error))
|
|
2768
|
+
if (this.died()) throw this.tooManyCrashes()
|
|
2641
2769
|
return this.launch(wasAt)
|
|
2642
2770
|
}).finally(() => {
|
|
2643
2771
|
this.starting = null
|
|
2644
2772
|
})
|
|
2645
2773
|
return this.starting
|
|
2646
2774
|
}
|
|
2775
|
+
const asked = Date.now()
|
|
2647
2776
|
this.starting = this.launch(options.url).finally(() => {
|
|
2648
2777
|
this.starting = null
|
|
2649
2778
|
})
|
|
2650
|
-
|
|
2779
|
+
/* How long a rest actually cost is the number that decides whether resting
|
|
2780
|
+
was worth it; a silent multi-second wake is a regression, not a detail. */
|
|
2781
|
+
return this.starting.then((page) => {
|
|
2782
|
+
if (Number(this.sleptAt ?? 0) > 0) {
|
|
2783
|
+
this.wakeMs = Date.now() - asked
|
|
2784
|
+
this.note(`woke in ${String(this.wakeMs)} ms`)
|
|
2785
|
+
this.sleptAt = 0
|
|
2786
|
+
}
|
|
2787
|
+
return page
|
|
2788
|
+
})
|
|
2651
2789
|
}
|
|
2652
2790
|
|
|
2653
2791
|
/**
|
|
@@ -2675,6 +2813,9 @@ export class Sidecar {
|
|
|
2675
2813
|
|
|
2676
2814
|
/** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
|
|
2677
2815
|
async launch(url) {
|
|
2816
|
+
/* A rest and the next call can collide: wait for the browser that is going
|
|
2817
|
+
out to be gone before starting the one that is coming in. */
|
|
2818
|
+
if (this.closing !== null) await this.closing.catch(() => {})
|
|
2678
2819
|
/* Chromium 87 opening the profile a current Chrome owns would downgrade it
|
|
2679
2820
|
and lock it, so the Flash-era browser keeps a profile of its own. The two
|
|
2680
2821
|
never share a directory; a game signed into one is signed in there only. */
|
|
@@ -2750,7 +2891,7 @@ export class Sidecar {
|
|
|
2750
2891
|
'--no-default-browser-check',
|
|
2751
2892
|
'--use-mock-keychain',
|
|
2752
2893
|
'--disable-blink-features=AutomationControlled',
|
|
2753
|
-
'--disable-features=Translate,MediaRouter,OptimizationHints',
|
|
2894
|
+
'--disable-features=Translate,MediaRouter,OptimizationHints,InterestFeedContentSuggestions,CalculateNativeWinOcclusion',
|
|
2754
2895
|
'--disable-sync',
|
|
2755
2896
|
/* The driver clicks for a person, and a page that opens a tab on a click
|
|
2756
2897
|
is doing what the page means to do. Blocking it would strand the
|
|
@@ -2767,6 +2908,24 @@ export class Sidecar {
|
|
|
2767
2908
|
'--disable-renderer-backgrounding',
|
|
2768
2909
|
'--disable-ipc-flooding-protection',
|
|
2769
2910
|
'--metrics-recording-only',
|
|
2911
|
+
/* Nothing here talks to Google while the person is not reading: the
|
|
2912
|
+
update, the crash reporter and the network pings all cost a process,
|
|
2913
|
+
a socket and a wake-up on a page that is only being looked at. */
|
|
2914
|
+
'--disable-background-networking',
|
|
2915
|
+
'--disable-component-update',
|
|
2916
|
+
'--disable-domain-reliability',
|
|
2917
|
+
'--disable-client-side-phishing-detection',
|
|
2918
|
+
'--disable-breakpad',
|
|
2919
|
+
'--disable-crash-reporter',
|
|
2920
|
+
'--no-pings',
|
|
2921
|
+
'--disable-extensions',
|
|
2922
|
+
'--disable-default-apps',
|
|
2923
|
+
/* A page nobody looks at can hand its heap back when it is parked; the
|
|
2924
|
+
renderer needs the flag before it will collect on request. */
|
|
2925
|
+
'--js-flags=--expose-gc',
|
|
2926
|
+
/* Six tabs never need six renderers: the cap keeps the resident cost of a
|
|
2927
|
+
browsing session flat, while four still keeps unrelated sites apart. */
|
|
2928
|
+
'--renderer-process-limit=4',
|
|
2770
2929
|
'--no-service-autorun',
|
|
2771
2930
|
'--password-store=basic',
|
|
2772
2931
|
'--lang=zh-CN',
|
|
@@ -2780,6 +2939,10 @@ export class Sidecar {
|
|
|
2780
2939
|
'--window-position=-3000,-3000',
|
|
2781
2940
|
'about:blank',
|
|
2782
2941
|
]
|
|
2942
|
+
/* An escape hatch for measuring one flag against another without editing
|
|
2943
|
+
* the source: space-separated extra switches, never a replacement. */
|
|
2944
|
+
const extra = String(process.env.DSH_INNER_BROWSER_CHROME_FLAGS ?? '').trim()
|
|
2945
|
+
if (extra !== '') args.push(...extra.split(/\s+/))
|
|
2783
2946
|
if (flash !== null) {
|
|
2784
2947
|
/* Electron takes these switches through its own command line, not the
|
|
2785
2948
|
argv it hands the app, so the runtime app reads them from here. */
|
|
@@ -3129,27 +3292,153 @@ export class Sidecar {
|
|
|
3129
3292
|
}
|
|
3130
3293
|
|
|
3131
3294
|
/**
|
|
3132
|
-
* Starts the idle clock again
|
|
3133
|
-
*
|
|
3295
|
+
* Starts the idle clock again: the browser was just asked for something, and
|
|
3296
|
+
* that ask is also the measurement that teaches it how long this chat pauses
|
|
3297
|
+
* between steps while it is still working.
|
|
3298
|
+
* @param {number} [ms] - an explicit window, when a pane the person closed is
|
|
3299
|
+
* a stronger signal than the learned rhythm.
|
|
3134
3300
|
*/
|
|
3135
|
-
touch(ms
|
|
3136
|
-
|
|
3301
|
+
touch(ms) {
|
|
3302
|
+
const now = Date.now()
|
|
3303
|
+
this.use.record(now)
|
|
3304
|
+
this.useAt = now
|
|
3305
|
+
this.restAt = now + (Number.isFinite(ms) ? Math.max(0, Number(ms)) : this.restWindow())
|
|
3306
|
+
this.armRest()
|
|
3307
|
+
this.wakeClock()
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
/**
|
|
3311
|
+
* Puts the precise edge on the clock: the beat would catch the deadline
|
|
3312
|
+
* anyway, this only makes it exact.
|
|
3313
|
+
*/
|
|
3314
|
+
armRest() {
|
|
3137
3315
|
if (this.idle !== null) clearTimeout(this.idle)
|
|
3316
|
+
if (this.restAt === 0) {
|
|
3317
|
+
this.idle = null
|
|
3318
|
+
return
|
|
3319
|
+
}
|
|
3320
|
+
/* The timer is the precise edge; the beat is what makes it survive a lost
|
|
3321
|
+
timer, a plugin reload or a laptop that slept through the deadline. */
|
|
3138
3322
|
this.idle = setTimeout(() => {
|
|
3139
3323
|
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)
|
|
3324
|
+
void this.beatNow().catch(() => {})
|
|
3325
|
+
}, Math.max(0, Number(this.restAt) - Date.now()))
|
|
3150
3326
|
this.idle.unref?.()
|
|
3151
3327
|
}
|
|
3152
3328
|
|
|
3329
|
+
/** @returns {number} ms of silence from this chat that still means "working". */
|
|
3330
|
+
restWindow() {
|
|
3331
|
+
return Math.min(this.restCeiling, Math.max(this.restFloor, this.use.restAfter()))
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
/** Starts the lifecycle beat while a browser is up. */
|
|
3335
|
+
wakeClock() {
|
|
3336
|
+
if (this.beat !== null || this.stopping === true) return
|
|
3337
|
+
this.beat = setInterval(() => {
|
|
3338
|
+
void this.beatNow().catch(() => {})
|
|
3339
|
+
}, BEAT_MS)
|
|
3340
|
+
this.beat.unref?.()
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
/** Stops the beat and any pending rest: the browser is gone or handed over. */
|
|
3344
|
+
sleepClock() {
|
|
3345
|
+
if (this.beat !== null) clearInterval(this.beat)
|
|
3346
|
+
this.beat = null
|
|
3347
|
+
if (this.idle !== null) clearTimeout(this.idle)
|
|
3348
|
+
this.idle = null
|
|
3349
|
+
this.restAt = 0
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
/**
|
|
3353
|
+
* One lifecycle beat: has the pause become a departure, and does the browser
|
|
3354
|
+
* cost more than it is being used for.
|
|
3355
|
+
* @returns {Promise<void>} done, possibly with the browser put away.
|
|
3356
|
+
*/
|
|
3357
|
+
async beatNow() {
|
|
3358
|
+
if (!this.alive || this.stopping === true) {
|
|
3359
|
+
this.sleepClock()
|
|
3360
|
+
return
|
|
3361
|
+
}
|
|
3362
|
+
const now = Date.now()
|
|
3363
|
+
const watched = this.watched()
|
|
3364
|
+
const verdict = decide({
|
|
3365
|
+
now,
|
|
3366
|
+
lastUse: this.useAt,
|
|
3367
|
+
restAt: this.restAt,
|
|
3368
|
+
watched,
|
|
3369
|
+
quietAfter: this.use.quietAfter(),
|
|
3370
|
+
})
|
|
3371
|
+
if (verdict === 'rest') {
|
|
3372
|
+
this.rests += 1
|
|
3373
|
+
this.sleptAt = now
|
|
3374
|
+
/* The reason outlives the browser: a chat asking later finds out why the
|
|
3375
|
+
page it left is gone, instead of a silent blank column. */
|
|
3376
|
+
this.note(`unused for ${String(Math.round((now - Number(this.useAt)) / 1000))} s with no pane watching; put away`)
|
|
3377
|
+
await this.shutdown().catch(() => {})
|
|
3378
|
+
return
|
|
3379
|
+
}
|
|
3380
|
+
if (verdict !== 'rest') {
|
|
3381
|
+
/* Someone is still watching, or the chat is still working: the clock
|
|
3382
|
+
starts again from now, and the next edge is scheduled. */
|
|
3383
|
+
if (watched > 0) this.restAt = now + this.restWindow()
|
|
3384
|
+
if (this.idle === null && this.restAt > 0) this.armRest()
|
|
3385
|
+
}
|
|
3386
|
+
if (verdict === 'quiet') {
|
|
3387
|
+
if (this.resting !== true) {
|
|
3388
|
+
this.resting = true
|
|
3389
|
+
/* A picture nobody looks at still costs a screencast and an encoded
|
|
3390
|
+
frame per beat. The page stays, the painting stops. */
|
|
3391
|
+
for (const page of [this.page, ...this.pages.values()]) {
|
|
3392
|
+
if (page === null || page === undefined) continue
|
|
3393
|
+
if ((page.frames?.size ?? 0) !== 0) continue
|
|
3394
|
+
if (typeof page.stopCast !== 'function') continue
|
|
3395
|
+
await page.stopCast().catch(() => {})
|
|
3396
|
+
/* Chrome's own Page Lifecycle park: timers and fetches stop, the DOM,
|
|
3397
|
+
the scroll position and the CDP session stay. No competitor drives
|
|
3398
|
+
this state at all, and it is the difference between a tab that has
|
|
3399
|
+
to be reloaded and one that resumes where it was. */
|
|
3400
|
+
await page.freeze().catch(() => {})
|
|
3401
|
+
}
|
|
3402
|
+
this.restNote = 'no pane is watching; the page is parked and the mirror paused'
|
|
3403
|
+
}
|
|
3404
|
+
} else if (this.resting === true) {
|
|
3405
|
+
this.resting = false
|
|
3406
|
+
this.restNote = ''
|
|
3407
|
+
}
|
|
3408
|
+
await this.weigh(now, watched)
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
/**
|
|
3412
|
+
* What the browser costs, and what to do about it: tabs nobody watches go
|
|
3413
|
+
* first, and the browser itself goes when nobody watches at all.
|
|
3414
|
+
* @param {number} now - the moment of this look.
|
|
3415
|
+
* @param {number} watched - panes watching a picture right now.
|
|
3416
|
+
* @returns {Promise<void>} done.
|
|
3417
|
+
*/
|
|
3418
|
+
async weigh(now, watched) {
|
|
3419
|
+
if (now - Number(this.memAt ?? 0) < SAMPLE_MS) return
|
|
3420
|
+
this.memAt = now
|
|
3421
|
+
const pid = Number(this.child?.pid ?? 0)
|
|
3422
|
+
this.memMb = pid > 0 ? Math.round(treeBytes(pid) / 1024 / 1024) : 0
|
|
3423
|
+
if (this.memMb < this.budgetMb) {
|
|
3424
|
+
this.pressure = 'ok'
|
|
3425
|
+
return
|
|
3426
|
+
}
|
|
3427
|
+
if (this.pressure === 'ok') {
|
|
3428
|
+
this.pressure = 'tight'
|
|
3429
|
+
this.note(`holding ${String(this.memMb)} MB; closing tabs no pane is watching`)
|
|
3430
|
+
await this.trimTabs().catch(() => {})
|
|
3431
|
+
await this.reapIdleTabs(true).catch(() => {})
|
|
3432
|
+
return
|
|
3433
|
+
}
|
|
3434
|
+
if (watched === 0) {
|
|
3435
|
+
this.pressure = 'high'
|
|
3436
|
+
this.rests += 1
|
|
3437
|
+
this.sleptAt = Date.now()
|
|
3438
|
+
await this.shutdown().catch(() => {})
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3153
3442
|
/** @returns {Promise<void>} resolves once Chrome is gone. */
|
|
3154
3443
|
/**
|
|
3155
3444
|
* Leaves the browser running for whoever loads this plugin next, and puts the
|
|
@@ -3161,6 +3450,7 @@ export class Sidecar {
|
|
|
3161
3450
|
*/
|
|
3162
3451
|
async handoff() {
|
|
3163
3452
|
this.stopping = true
|
|
3453
|
+
this.sleepClock()
|
|
3164
3454
|
if (this.page !== null) await this.saveSessionCookies()
|
|
3165
3455
|
if (this.backup !== undefined) clearInterval(this.backup)
|
|
3166
3456
|
this.backup = undefined
|
|
@@ -3225,7 +3515,25 @@ export class Sidecar {
|
|
|
3225
3515
|
}
|
|
3226
3516
|
|
|
3227
3517
|
async shutdown() {
|
|
3518
|
+
/* One at a time: a rest that fires while a call already asked the browser
|
|
3519
|
+
to close must not close the socket that call is about to use. */
|
|
3520
|
+
if (this.closing !== null) return this.closing
|
|
3521
|
+
const going = this.putAway()
|
|
3522
|
+
this.closing = going
|
|
3523
|
+
try {
|
|
3524
|
+
await going
|
|
3525
|
+
} finally {
|
|
3526
|
+
if (this.closing === going) this.closing = null
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
/** Put the browser away once; `shutdown()` keeps two of these from overlapping.
|
|
3531
|
+
* @returns {Promise<void>} done. */
|
|
3532
|
+
async putAway() {
|
|
3228
3533
|
this.idleAt = 0
|
|
3534
|
+
/* Nothing to wait for once the browser is going: a beat that fired into a
|
|
3535
|
+
stopped browser would try to put it away a second time. */
|
|
3536
|
+
this.sleepClock()
|
|
3229
3537
|
/* A browser this process did not start belongs to somebody else — another
|
|
3230
3538
|
app on the same profile, or a script that connected to it — and closing
|
|
3231
3539
|
it would close the person's window. Let go of it instead: drop the socket
|
|
@@ -3391,9 +3699,32 @@ export class Sidecar {
|
|
|
3391
3699
|
/* What this browser costs right now: the whole process tree, renderers
|
|
3392
3700
|
included. Reported so a chat can decide whether to let it rest. */
|
|
3393
3701
|
memory: this.alive ? treeBytes(Number(this.child?.pid ?? 0)) : 0,
|
|
3394
|
-
/* When it would
|
|
3702
|
+
/* When it would be put away if nobody came back, and why: 0 means the
|
|
3395
3703
|
browser is either watched or not on a clock. */
|
|
3396
|
-
closesIn: this.
|
|
3704
|
+
closesIn: this.restAt === 0 || this.stopping === true ? 0 : Math.max(0, Number(this.restAt) - Date.now()),
|
|
3705
|
+
/* The learned rhythm: the rest window this chat earned, and the quiet
|
|
3706
|
+
window it is given before the mirror is paused. */
|
|
3707
|
+
restIn: this.restAt === 0 ? 0 : Math.max(0, Number(this.restAt) - Date.now()),
|
|
3708
|
+
restWindow: this.use.restAfter(),
|
|
3709
|
+
quietAfter: this.use.quietAfter(),
|
|
3710
|
+
typicalGap: this.use.typical(),
|
|
3711
|
+
/* What the lifecycle beat last found. */
|
|
3712
|
+
resting: this.resting === true,
|
|
3713
|
+
restNote: this.restNote ?? '',
|
|
3714
|
+
/* How many times it has been put down, how long the last wake took, and
|
|
3715
|
+
how long it has been waiting for a wake right now. */
|
|
3716
|
+
rests: this.rests,
|
|
3717
|
+
sleptAt: this.sleptAt,
|
|
3718
|
+
wakeMs: this.wakeMs,
|
|
3719
|
+
/* Memory policy, so a chat can see the ladder before it is climbed. */
|
|
3720
|
+
memoryMb: this.memMb,
|
|
3721
|
+
budgetMb: this.budgetMb,
|
|
3722
|
+
pressure: this.pressure,
|
|
3723
|
+
/* Parked pages and the restarts that happened recently: a browser that
|
|
3724
|
+
has to be restarted three times in ten minutes is broken, and saying so
|
|
3725
|
+
is better than restarting it for ever. */
|
|
3726
|
+
parked: [this.page, ...this.pages.values()].filter((page) => page?.frozen === true).length,
|
|
3727
|
+
restarts: this.crashes.length,
|
|
3397
3728
|
error: this.alive ? '' : this.lastError,
|
|
3398
3729
|
}
|
|
3399
3730
|
}
|
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
|
|