@frockbot/computer-host-runtime 0.3.20 → 0.3.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/computer-host-runtime",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,10 +28,15 @@ import {
28
28
  BROWSER_ENSURE_ACTION,
29
29
  BROWSER_FOCUS_ACTION,
30
30
  BROWSER_SURVEY_ACTION,
31
+ browserWatchdogScript,
31
32
  CHROME_LAUNCHER,
32
33
  CHROME_PROFILE,
33
34
  chromeLauncherScript,
35
+ CHROMIUM_DISABLED_FEATURES,
36
+ CHROMIUM_FLAGS,
37
+ CHROMIUM_MAX_OLD_SPACE_MIB,
34
38
  CHROMIUM_PATH,
39
+ CHROMIUM_RENDERER_PROCESS_LIMIT,
35
40
  COMPUTER_CDP_PORT,
36
41
  COMPUTER_DISPLAY,
37
42
  DESKTOP_SLOTS,
@@ -89,6 +94,10 @@ import {
89
94
  SLOT_IDLE_SECONDS,
90
95
  UPDATE_PHASES,
91
96
  updateLaunchScript,
97
+ WATCHDOG_LOG,
98
+ WATCHDOG_MEM_AVAILABLE_FLOOR_KIB,
99
+ WATCHDOG_RENDERER_RSS_LIMIT_KIB,
100
+ WATCHDOG_SCRIPT,
92
101
  WORKSPACES_ROOT,
93
102
  } from "./runtime.ts";
94
103
 
@@ -720,6 +729,136 @@ describe("installed shell scripts", () => {
720
729
  expect(launcher).not.toContain(`rm -rf ${CHROME_PROFILE}`);
721
730
  });
722
731
 
732
+ test("bounds Chromium renderers without disabling background timer throttling", () => {
733
+ expect(CHROMIUM_FLAGS).toContain(
734
+ `--renderer-process-limit=${CHROMIUM_RENDERER_PROCESS_LIMIT}`,
735
+ );
736
+ expect(CHROMIUM_FLAGS).toContain(
737
+ `--js-flags=--max-old-space-size=${CHROMIUM_MAX_OLD_SPACE_MIB}`,
738
+ );
739
+ expect(CHROMIUM_FLAGS).toContain(
740
+ `--disable-features=${CHROMIUM_DISABLED_FEATURES.join(",")}`,
741
+ );
742
+ expect(CHROMIUM_DISABLED_FEATURES).not.toContain(
743
+ "IntensiveWakeUpThrottling",
744
+ );
745
+ });
746
+
747
+ test("the watchdog kills an oversized renderer and leaves a bounded one running", async () => {
748
+ const directory = await mkdtemp(join(tmpdir(), "frockbot-watchdog-"));
749
+ const oversized = Bun.spawn(["sleep", "60"], {
750
+ stdout: "ignore",
751
+ stderr: "ignore",
752
+ });
753
+ const bounded = Bun.spawn(["sleep", "60"], {
754
+ stdout: "ignore",
755
+ stderr: "ignore",
756
+ });
757
+ try {
758
+ const procRoot = join(directory, "proc");
759
+ const renderer = async (pid: number, rssKiB: number) => {
760
+ const root = join(procRoot, String(pid));
761
+ await mkdir(root, { recursive: true });
762
+ await writeFile(
763
+ join(root, "cmdline"),
764
+ `/home/box/bin/chromium\0--type=renderer\0--renderer-client-id=${pid}\0`,
765
+ );
766
+ await writeFile(
767
+ join(root, "status"),
768
+ `Name:\tchromium\nState:\tS (sleeping)\nVmRSS:\t${rssKiB} kB\n`,
769
+ );
770
+ };
771
+ await mkdir(procRoot, { recursive: true });
772
+ await writeFile(
773
+ join(procRoot, "meminfo"),
774
+ "MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n",
775
+ );
776
+ await renderer(oversized.pid, WATCHDOG_RENDERER_RSS_LIMIT_KIB + 524_288);
777
+ await renderer(bounded.pid, WATCHDOG_RENDERER_RSS_LIMIT_KIB - 1);
778
+ const logPath = join(directory, "watchdog.log");
779
+ const watchdog = Bun.spawn(["bash"], {
780
+ stdin: new Blob([browserWatchdogScript]),
781
+ stdout: "pipe",
782
+ stderr: "pipe",
783
+ env: {
784
+ ...process.env,
785
+ FROCKBOT_WATCHDOG_ONCE: "1",
786
+ FROCKBOT_WATCHDOG_PROC_ROOT: procRoot,
787
+ FROCKBOT_WATCHDOG_LOG: logPath,
788
+ },
789
+ });
790
+
791
+ const [watchdogExit, watchdogError] = await Promise.all([
792
+ watchdog.exited,
793
+ new Response(watchdog.stderr).text(),
794
+ ]);
795
+ expect(watchdogError).toBe("");
796
+ expect(watchdogExit).toBe(0);
797
+ expect(await oversized.exited).not.toBe(0);
798
+ expect(bounded.exitCode).toBeNull();
799
+ const log = await readFile(logPath, "utf8");
800
+ expect(log).toContain(`pid=${oversized.pid}`);
801
+ expect(log).toContain("reason=renderer-rss");
802
+ expect(log).not.toContain(`pid=${bounded.pid}`);
803
+ } finally {
804
+ oversized.kill();
805
+ bounded.kill();
806
+ await rm(directory, { recursive: true, force: true });
807
+ }
808
+ });
809
+
810
+ test("low available memory closes only the worst renderer needed for the floor", async () => {
811
+ const directory = await mkdtemp(join(tmpdir(), "frockbot-low-memory-"));
812
+ const worst = Bun.spawn(["sleep", "60"], {
813
+ stdout: "ignore",
814
+ stderr: "ignore",
815
+ });
816
+ const smaller = Bun.spawn(["sleep", "60"], {
817
+ stdout: "ignore",
818
+ stderr: "ignore",
819
+ });
820
+ try {
821
+ const procRoot = join(directory, "proc");
822
+ await mkdir(procRoot, { recursive: true });
823
+ await writeFile(
824
+ join(procRoot, "meminfo"),
825
+ `MemAvailable: ${WATCHDOG_MEM_AVAILABLE_FLOOR_KIB - 262_144} kB\n`,
826
+ );
827
+ for (const [pid, rssKiB] of [
828
+ [worst.pid, 393_216],
829
+ [smaller.pid, 262_144],
830
+ ] as const) {
831
+ const root = join(procRoot, String(pid));
832
+ await mkdir(root, { recursive: true });
833
+ await writeFile(join(root, "cmdline"), `chromium\0--type=renderer\0`);
834
+ await writeFile(join(root, "status"), `VmRSS:\t${rssKiB} kB\n`);
835
+ }
836
+ const logPath = join(directory, "watchdog.log");
837
+ const watchdog = Bun.spawn(["bash"], {
838
+ stdin: new Blob([browserWatchdogScript]),
839
+ stdout: "ignore",
840
+ stderr: "pipe",
841
+ env: {
842
+ ...process.env,
843
+ FROCKBOT_WATCHDOG_ONCE: "1",
844
+ FROCKBOT_WATCHDOG_PROC_ROOT: procRoot,
845
+ FROCKBOT_WATCHDOG_LOG: logPath,
846
+ },
847
+ });
848
+ expect(await watchdog.exited).toBe(0);
849
+ expect(await worst.exited).not.toBe(0);
850
+ expect(smaller.exitCode).toBeNull();
851
+ const log = await readFile(logPath, "utf8");
852
+ expect(log).toContain(`pid=${worst.pid}`);
853
+ expect(log).toContain("reason=low-mem-available");
854
+ expect(log).not.toContain(`pid=${smaller.pid}`);
855
+ } finally {
856
+ worst.kill();
857
+ smaller.kill();
858
+ await rm(directory, { recursive: true, force: true });
859
+ }
860
+ });
861
+
723
862
  test("one screen carries every slot, and each viewer is clipped to one", () => {
724
863
  const screen = installedScript(
725
864
  provisionScript,
@@ -793,6 +932,7 @@ describe("installed shell scripts", () => {
793
932
  ENSURE_AGENT_SCRIPT,
794
933
  CONTROL_SCRIPT,
795
934
  BOUNDED_LOG_SCRIPT,
935
+ WATCHDOG_SCRIPT,
796
936
  CHROME_LAUNCHER,
797
937
  DOCTOR_SCRIPT,
798
938
  `${RUNTIME_ROOT}/start-gateway.sh`,
@@ -1333,6 +1473,8 @@ describe("box-doctor", () => {
1333
1473
  "scratch",
1334
1474
  "desktop-gateway",
1335
1475
  "sync-watcher",
1476
+ "watchdog",
1477
+ "memory-top",
1336
1478
  "browser-process",
1337
1479
  "browser-cdp",
1338
1480
  "screen",
package/src/runtime.ts CHANGED
@@ -386,6 +386,22 @@ export function shellGuiCommandV1(command: string): string | undefined {
386
386
  /** The one browser profile every Bot of one User shares (ADR 0012). */
387
387
  export const CHROME_PROFILE = `${HOME_ROOT}/chrome-profile`;
388
388
 
389
+ /** Maximum Chromium renderer processes on one shared 8 GiB Computer. */
390
+ export const CHROMIUM_RENDERER_PROCESS_LIMIT = 8;
391
+ /** V8 old-space ceiling in each renderer; native allocations remain possible. */
392
+ export const CHROMIUM_MAX_OLD_SPACE_MIB = 1024;
393
+ /**
394
+ * Features disabled for a shared unattended desktop.
395
+ *
396
+ * Native window occlusion is meaningless under Xvfb and can stop painting a
397
+ * window that is visible only through VNC. Background timer throttling stays
398
+ * enabled: an unattended preview tab must not earn more CPU merely because a
399
+ * Bot left it open.
400
+ */
401
+ export const CHROMIUM_DISABLED_FEATURES = [
402
+ "CalculateNativeWinOcclusion",
403
+ ] as const;
404
+
389
405
  /** The browser flags the Computer runs chromium under, in one place. */
390
406
  export const CHROMIUM_FLAGS: readonly string[] = [
391
407
  "--no-sandbox",
@@ -401,6 +417,9 @@ export const CHROMIUM_FLAGS: readonly string[] = [
401
417
  "--window-position=0,0",
402
418
  "--no-first-run",
403
419
  "--no-default-browser-check",
420
+ `--renderer-process-limit=${CHROMIUM_RENDERER_PROCESS_LIMIT}`,
421
+ `--js-flags=--max-old-space-size=${CHROMIUM_MAX_OLD_SPACE_MIB}`,
422
+ `--disable-features=${CHROMIUM_DISABLED_FEATURES.join(",")}`,
404
423
  ];
405
424
 
406
425
  export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
@@ -550,6 +569,84 @@ fi
550
569
  exec ${CHROME_LAUNCHER} about:blank
551
570
  `;
552
571
 
572
+ /** The renderer watchdog, supervised independently of Chromium. */
573
+ export const WATCHDOG_SERVICE = "frockbot-browser-watchdog";
574
+ /** The installed watchdog executable. */
575
+ export const WATCHDOG_SCRIPT = `${RUNTIME_ROOT}/browser-watchdog.sh`;
576
+ /** Its bounded, durable-on-the-Computer action log. */
577
+ export const WATCHDOG_LOG = `${RUNTIME_ROOT}/watchdog.log`;
578
+ /** 1.5 GiB RSS: one renderer may not consume a material fraction of the box. */
579
+ export const WATCHDOG_RENDERER_RSS_LIMIT_KIB = 1_572_864;
580
+ /** Keep 512 MiB available for the Agent transport and ordinary commands. */
581
+ export const WATCHDOG_MEM_AVAILABLE_FLOOR_KIB = 524_288;
582
+ /** How often the supervised watchdog samples `/proc`. */
583
+ export const WATCHDOG_INTERVAL_SECONDS = 30;
584
+ /** Bound the durable diagnostic log without requiring logrotate. */
585
+ export const WATCHDOG_LOG_MAX_LINES = 200;
586
+
587
+ /**
588
+ * Kills only Chromium renderer processes, never the shared browser process.
589
+ *
590
+ * Every renderer over the individual ceiling is killed. Under box-wide
591
+ * pressure, the largest remaining renderers are killed until their reclaimed
592
+ * RSS would restore the available-memory floor. Chromium owns renderer crash
593
+ * recovery, so the browser, profile, and other Bots' windows remain resident.
594
+ */
595
+ export const browserWatchdogScript = `#!/usr/bin/env bash
596
+ set -u
597
+ PROC_ROOT="\${FROCKBOT_WATCHDOG_PROC_ROOT:-/proc}"
598
+ LOG="\${FROCKBOT_WATCHDOG_LOG:-${WATCHDOG_LOG}}"
599
+ mkdir -p "$(dirname "$LOG")"
600
+ touch "$LOG"
601
+
602
+ trim_log() {
603
+ LINES=$(wc -l < "$LOG" 2>/dev/null || echo 0)
604
+ if [ "$LINES" -gt ${WATCHDOG_LOG_MAX_LINES} ]; then
605
+ tail -n ${WATCHDOG_LOG_MAX_LINES} "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
606
+ fi
607
+ }
608
+
609
+ while true; do
610
+ MEM_AVAILABLE=$(awk '/^MemAvailable:/ { print $2; exit }' "$PROC_ROOT/meminfo" 2>/dev/null || true)
611
+ # If the kernel did not provide a reading, keep enforcing the per-renderer
612
+ # bound but do not infer box-wide pressure and kill otherwise healthy tabs.
613
+ case "$MEM_AVAILABLE" in (''|*[!0-9]*) MEM_AVAILABLE=${WATCHDOG_MEM_AVAILABLE_FLOOR_KIB};; esac
614
+ CANDIDATES=$(
615
+ for STATUS in "$PROC_ROOT"/[0-9]*/status; do
616
+ [ -f "$STATUS" ] || continue
617
+ PID=$(basename "$(dirname "$STATUS")")
618
+ CMDLINE=$(tr '\\000' ' ' < "$(dirname "$STATUS")/cmdline" 2>/dev/null || true)
619
+ printf '%s' "$CMDLINE" | grep -q -- '--type=renderer' || continue
620
+ RSS=$(awk '/^VmRSS:/ { print $2; exit }' "$STATUS" 2>/dev/null || true)
621
+ case "$RSS" in (''|*[!0-9]*) continue;; esac
622
+ printf '%s %s\\n' "$RSS" "$PID"
623
+ done | sort -rn
624
+ )
625
+ PROJECTED_AVAILABLE=$MEM_AVAILABLE
626
+ while read -r RSS PID; do
627
+ [ -n "\${PID:-}" ] || continue
628
+ REASON=""
629
+ if [ "$RSS" -gt ${WATCHDOG_RENDERER_RSS_LIMIT_KIB} ]; then
630
+ REASON=renderer-rss
631
+ elif [ "$PROJECTED_AVAILABLE" -lt ${WATCHDOG_MEM_AVAILABLE_FLOOR_KIB} ]; then
632
+ REASON=low-mem-available
633
+ else
634
+ break
635
+ fi
636
+ if kill -9 "$PID" 2>/dev/null; then
637
+ PROJECTED_AVAILABLE=$((PROJECTED_AVAILABLE + RSS))
638
+ printf '%s action=closed-renderer pid=%s rssMiB=%s memAvailableMiB=%s reason=%s\\n' \
639
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PID" "$((RSS / 1024))" "$((MEM_AVAILABLE / 1024))" "$REASON" >> "$LOG"
640
+ fi
641
+ done <<EOF
642
+ $CANDIDATES
643
+ EOF
644
+ trim_log
645
+ [ "\${FROCKBOT_WATCHDOG_ONCE:-}" = 1 ] && exit 0
646
+ sleep ${WATCHDOG_INTERVAL_SECONDS}
647
+ done
648
+ `;
649
+
553
650
  /**
554
651
  * One Bot's viewer: an `x11vnc` clipped to that Bot's slot of the one screen.
555
652
  *
@@ -1023,6 +1120,35 @@ if (action.action === "focus") {
1023
1120
  await done({ focused: Boolean(focusPage), ...(anchor ? { targetId: anchor.targetId } : {}) });
1024
1121
  }
1025
1122
 
1123
+ // Lifecycle cleanup from the Computer Package. Origins come from a preview
1124
+ // process's own bounded log and the action is already scoped to this Bot, but
1125
+ // enforce the window boundary again here: a shared browser profile never
1126
+ // makes another Bot's tab ours to close. If the recorded anchor was one of the
1127
+ // closed pages, adopt a surviving tab in the same window; only create a blank
1128
+ // replacement when the window has no page left.
1129
+ if (action.action === "close-origins") {
1130
+ const origins = new Set(action.origins);
1131
+ const candidates = anchor ? await pagesInWindow(anchor.windowId) : [];
1132
+ let closed = 0;
1133
+ for (const candidate of candidates) {
1134
+ let origin = "";
1135
+ try {
1136
+ origin = new URL(candidate.url()).origin;
1137
+ } catch {}
1138
+ if (!origins.has(origin)) continue;
1139
+ await candidate.close();
1140
+ closed += 1;
1141
+ }
1142
+ const remaining = anchor ? await pagesInWindow(anchor.windowId) : [];
1143
+ if (remaining.length > 0) {
1144
+ const targetId = await targetIdOf(remaining.at(-1));
1145
+ writeFileSync(targetPath(botKey), \`\${targetId}\\n\`, { mode: 0o600 });
1146
+ } else if (anchor) {
1147
+ await ensureWindow(botKey);
1148
+ }
1149
+ await done({ closed, origins: [...origins], snapshot: "" });
1150
+ }
1151
+
1026
1152
  const own = anchor ? await pagesInWindow(anchor.windowId) : [];
1027
1153
  const page =
1028
1154
  own.at(-1) ??
@@ -1265,7 +1391,7 @@ export const CLOCK_FLOOR_EPOCH = 1_756_684_800;
1265
1391
  * corrected. The version is compared on every adoption instead, and the whole
1266
1392
  * set is rewritten when it moves. Bump it whenever a document below changes.
1267
1393
  */
1268
- export const REFERENCE_DOCS_VERSION = "2026-09-04.1";
1394
+ export const REFERENCE_DOCS_VERSION = "2026-09-05.1";
1269
1395
 
1270
1396
  /**
1271
1397
  * What a Bot reads to debug its own Computer.
@@ -1398,6 +1524,16 @@ more: it holds the flag set and starts the Computer's one browser on the one
1398
1524
  display and the one CDP port. The \`${BROWSER_SERVICE}\` service calls it, and
1399
1525
  nothing else needs to know the flags exist.
1400
1526
 
1527
+ The launcher limits Chromium to ${CHROMIUM_RENDERER_PROCESS_LIMIT} renderer
1528
+ processes and caps each renderer's V8 old space at
1529
+ ${CHROMIUM_MAX_OLD_SPACE_MIB} MiB. It disables only
1530
+ \`${CHROMIUM_DISABLED_FEATURES.join(",")}\`; background-tab timer throttling
1531
+ remains enabled. Native allocations can still exceed V8's heap ceiling, so
1532
+ the separately supervised \`${WATCHDOG_SERVICE}\` samples memory every
1533
+ ${WATCHDOG_INTERVAL_SECONDS} seconds. It closes a renderer above 1.5 GiB RSS,
1534
+ or the largest renderer when the Computer has less than 512 MiB available,
1535
+ and records the action in \`${WATCHDOG_LOG}\`.
1536
+
1401
1537
  ## What is never run from the shell
1402
1538
 
1403
1539
  ${COMPUTER_GUI_SHELL_COMMANDS.map((name) => `\`${name}\``).join(", ")}.
@@ -1420,7 +1556,8 @@ tools do.
1420
1556
  \`computer_doctor\` runs \`${DOCTOR_SCRIPT}\` and hands back a report: disk on
1421
1557
  \`/\` and \`${HOME_ROOT}\`, the size of \`${SCRATCH_ROOT}\`, the viewer
1422
1558
  gateway, the durable-root watcher, the shared screen, the one browser process
1423
- and its CDP port, every Bot's window and whether it sits over that Bot's own
1559
+ and its CDP port, the renderer watchdog's recent actions, the top memory
1560
+ consumers, every Bot's window and whether it sits over that Bot's own
1424
1561
  slot, the browser build and its profile, the sync signal and any conflicting
1425
1562
  generations, this reference
1426
1563
  set's version, the launcher and its shims, the clock, DNS, and whether a
@@ -1448,6 +1585,9 @@ Computer rather than of one run.
1448
1585
  - **browser-process** — none, or more than one. One is the whole design: the
1449
1586
  profile's lock admits exactly one browser, and a second one is a Bot with a
1450
1587
  black screen.
1588
+ - **watchdog** — the renderer memory guard is not running. Its last actions
1589
+ remain in \`${WATCHDOG_LOG}\`; opening the Computer repairs its supervised
1590
+ service.
1451
1591
  - **reference-docs** — this set is stale and refreshes when the Computer is
1452
1592
  next opened. Nothing you can do on the box fixes it.
1453
1593
  - **browser** — the browser build is missing. It is installed by provisioning,
@@ -1469,7 +1609,7 @@ is dropped.
1469
1609
 
1470
1610
  ## Logs on the box
1471
1611
 
1472
- \`${DOCTOR_LOG}\`, and per-Bot under \`${BOTS_ROOT}/<botKey>\`:
1612
+ \`${DOCTOR_LOG}\`, \`${WATCHDOG_LOG}\`, and per-Bot under \`${BOTS_ROOT}/<botKey>\`:
1473
1613
  \`chromium.log\`, \`fluxbox.log\`, \`x11vnc.log\`, and \`processes/<id>/log.*\`.
1474
1614
  Provisioning's own log is \`${RUNTIME_ROOT}/provision/provision.log\`.
1475
1615
  `,
@@ -1781,6 +1921,14 @@ if pgrep -f watch-workspace.sh >/dev/null 2>&1; then
1781
1921
  else
1782
1922
  record sync-watcher fail "no durable-root watcher is running; on-Computer writes will not signal a sync"
1783
1923
  fi
1924
+ WATCHDOG_ACTIONS=$(tail -n 5 ${WATCHDOG_LOG} 2>/dev/null | tr '\n' ';' || true)
1925
+ if pgrep -f -- ${WATCHDOG_SCRIPT} >/dev/null 2>&1; then
1926
+ record watchdog pass "the renderer watchdog is running; recent actions: \${WATCHDOG_ACTIONS:-none}"
1927
+ else
1928
+ record watchdog fail "the renderer watchdog is not running; recent actions: \${WATCHDOG_ACTIONS:-none}"
1929
+ fi
1930
+ TOP_MEMORY=$(ps -eo pid=,rss=,comm= --sort=-rss 2>/dev/null | head -n 5 | tr '\n' ';' || true)
1931
+ record memory-top pass "top resident-memory consumers (pid rssKiB command): \${TOP_MEMORY:-unavailable}"
1784
1932
  SLOT=""
1785
1933
  if [ -n "$KEY" ] && [ -s ${BOTS_ROOT}/"$KEY"/slot ]; then SLOT=$(cat ${BOTS_ROOT}/"$KEY"/slot); fi
1786
1934
  # One browser, one CDP port (ADR 0031). A second main process would mean a
@@ -1966,6 +2114,7 @@ export const COMPUTER_RUNTIME_FILES: readonly {
1966
2114
  content: startBrowserScript,
1967
2115
  mode: 0o700,
1968
2116
  },
2117
+ { path: WATCHDOG_SCRIPT, content: browserWatchdogScript, mode: 0o700 },
1969
2118
  {
1970
2119
  path: `${RUNTIME_ROOT}/start-view.sh`,
1971
2120
  content: startViewScript,