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/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
- async reapIdleTabs() {
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
- return this.starting
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
- * @param {number} [ms] - how long a browser nobody watches may stay up.
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 = IDLE_MS) {
3136
- this.idleAt = Date.now() + ms
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
- /* 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)
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 close itself if nobody came back, and why: 0 means the
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.idle === null || this.stopping === true ? 0 : Math.max(0, Number(this.idleAt ?? 0) - Date.now()),
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
- 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