From 9f3542fde65e80e4fb79a57457252b55631788f9 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:56:01 -0600 Subject: [PATCH] Cap the whole heap in browsers with a maxHeap option to cheerpjInit --- docs/browser.md | 27 ++++++++++++ docs/node.md | 2 +- src/browser/api.ts | 7 ++- src/browser/protocol.ts | 2 + src/browser/worker.ts | 6 +++ src/heapsize.ts | 5 +++ src/interp.ts | 9 +++- src/jvm.ts | 11 +++-- src/node.ts | 9 +--- src/runtime.ts | 98 ++++++++++++++++++++++++++++++++++++----- test/heapcheck.test.ts | 66 +++++++++++++++++++++++++++ 11 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 src/heapsize.ts create mode 100644 test/heapcheck.test.ts diff --git a/docs/browser.md b/docs/browser.md index 58a1e72..374a2dc 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -37,6 +37,7 @@ and `cjFileBlob(path)` also work. `total` is 0 while the server has not sent a length; the last call has `done === total`. - `cheerpjInit({ warmSnapshot: true })` saves a library VM once its calls have warmed it up (see below). - `cheerpjInit({ debug: true })` logs VM events to the console, such as whether a warm snapshot was saved, skipped as busy (and why) or restored. +- `cheerpjInit({ maxHeap: "256m" })` sets what `Runtime.maxMemory()` reports and caps live Java memory (see below). It takes bytes or a size with `k`, `m` or `g`, as `-Xmx` does. CheerpJ has no such option. - A file Java closes under `/files/downloads/` is downloaded by the browser. `/files/uploads/` exists for files the page adds there. - The browser console gets whole lines, so two `print` calls and a `println` log one @@ -124,6 +125,32 @@ array argument is a copy; what the native writes to it goes back to Java when it returns. Output goes to the element with id `console`, or to the display. `/files/` is writable storage, kept in IndexedDB. +## Memory limit + +Without `maxHeap`, a browser page caps only a single array (at 1 GB). A Java +program that keeps allocating grows until the tab dies. With `maxHeap`, the VM +also adds up the live bytes of every array of 4 KB or more, and throws +`OutOfMemoryError: Java heap space` when the sum stays over the cap. Objects and +smaller arrays do not count, so a program that only allocates those is not +capped. The cap is at most 1 GB. + +Browsers have no `gc()`, and `performance.memory` does not exist in a Worker, so +the VM counts arrays itself, from allocation until the engine collects the array +and runs its finalizer. An array Java dropped keeps counting until then. When +the sum passes the cap, the VM allocates a throwaway 256 MB buffer, which makes +Chromium collect, and pauses its threads for a few milliseconds at a time. It +throws only when finalizers have stopped running for 100 ms with the sum still +over the cap, or after 1 s over. A program that drops its data is not stopped, +and one that holds it is stopped slightly past the cap. In headless Chromium, a +loop that keeps 256 KB arrays held 1.06 times the cap for `maxHeap: "256m"`, 1.1 +times for `"64m"` and 1.3 times for `"32m"`, and the error came after about +0.1 s. + +The check needs `FinalizationRegistry` (Chrome 84, Firefox 79, Safari 14.1). +Where it is missing, only the per-array cap applies. It has been run in Chromium +only; other engines may collect later and stop a program further past the cap. +The snapshot cache is per `maxHeap` value. + After a VM boots, the worker keeps a snapshot of it, in memory and in IndexedDB (the 4 most recently used). Later runs with the same class path and `cheerpjInit` options start from it instead of booting, in this page and in diff --git a/docs/node.md b/docs/node.md index bb3e525..51b4ab8 100644 --- a/docs/node.md +++ b/docs/node.md @@ -19,7 +19,7 @@ npx @project516/java-web [options] -jar app.jar [args] | `--enable-preview` | preview features | | `--add-exports`, `--add-opens`, `--add-reads`, `--add-modules`, `--module-path`, `-p` | module options, as with `java` | | `--locale-data` | locales other than English (see [support.md](support.md#locales)) | -| `-Xmx` | the maximum heap reported by `Runtime.maxMemory()` (at most 1g, rounded up to 2 MB steps, below 2m an error). Under Node, java-web checks measured heap use periodically during allocation and throws `OutOfMemoryError: Java heap space` when live use stays over the cap after collection. Without an exposed garbage collector, it checks after estimated allocations exceed the cap. The browser still checks only individual array sizes. | +| `-Xmx` | the maximum heap reported by `Runtime.maxMemory()` (at most 1g, rounded up to 2 MB steps, below 2m an error). Under Node, java-web checks measured heap use periodically during allocation and throws `OutOfMemoryError: Java heap space` when live use stays over the cap after collection. Without an exposed garbage collector, it checks after estimated allocations exceed the cap. Pages set the cap with `maxHeap` ([browser.md](browser.md)). | | `--max-rss MB` | exit when resident memory passes this (default 2048) | | `--jdk DIR` | a java-web JDK image instead of the packaged one | | `--debug` | log VM events | diff --git a/src/browser/api.ts b/src/browser/api.ts index 70711ec..adeed8b 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -10,6 +10,7 @@ import { Bridge, messageOf, nativeTable } from "../bridge.ts"; import type { LibraryReply, LibraryRequest } from "../library.ts"; +import { heapSize } from "../heapsize.ts"; import type { FromWorker, ToWorker } from "./protocol.ts"; const LM_CLASS = 1, LM_JAR = 2; @@ -33,6 +34,8 @@ export interface InitOptions { warmSnapshot?: boolean; // log VM events (snapshots, missing natives) to the console, as --debug does under Node debug?: boolean; + // what Runtime.maxMemory() reports and the cap on live Java memory: bytes, or "256m" as in -Xmx + maxHeap?: number | string; // where java-web.js, the worker and jdk/ live; defaults to the script's directory runtime?: string; // receives output instead of the page console element @@ -153,6 +156,8 @@ export function cheerpjInit(options: InitOptions = {}): Promise { output = options.onOutput ?? null; const props = keyValues(options.javaProperties); const env = keyValues(options.env); + const maxHeap = options.maxHeap === undefined ? 0 : typeof options.maxHeap === "number" ? options.maxHeap : /^\d+[kmg]?$/i.test(options.maxHeap) ? heapSize(options.maxHeap) : NaN; + if (!(maxHeap === 0 || maxHeap >= 2 ** 21)) throw new Error(`java-web: invalid maxHeap ${options.maxHeap}`); worker = new Worker(new URL("java-web-worker.js", runtime)); ready = new Promise((resolve, reject) => { worker!.onmessage = (e: MessageEvent) => { @@ -182,7 +187,7 @@ export function cheerpjInit(options: InitOptions = {}): Promise { }; }); natives = options.natives ?? {}; - send({ type: "init", runtime, app: appBase, props, hostName: options.hostName, env, localeData: !!options.localeData, warmSnapshot: !!options.warmSnapshot, debug: !!options.debug, natives: Object.keys(natives), progress: !!options.preloadProgress }); + send({ type: "init", runtime, app: appBase, props, hostName: options.hostName, env, localeData: !!options.localeData, warmSnapshot: !!options.warmSnapshot, debug: !!options.debug, maxHeap, natives: Object.keys(natives), progress: !!options.preloadProgress }); initStarted(ready); return ready; } diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index f30b724..e2c771a 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -14,6 +14,8 @@ export interface InitMessage { localeData: boolean; warmSnapshot: boolean; debug: boolean; + // bytes; 0 for the default + maxHeap: number; // JNI names of natives the page implements natives: string[]; // report the download of the runtime diff --git a/src/browser/worker.ts b/src/browser/worker.ts index e2a3796..bc914e8 100644 --- a/src/browser/worker.ts +++ b/src/browser/worker.ts @@ -4,6 +4,7 @@ import { MemoryBackend, VFS } from "../fs.ts"; import { JVM, SnapshotError } from "../jvm.ts"; +import { MAX_HEAP, resetAllocationCheck, setAllocationCheck, setMaxHeap, trackArrays } from "../runtime.ts"; import { HttpBackend, parsePack } from "./http.ts"; import { dropSnapshot, dropWarmSnapshot, loadSnapshot, loadWarmSnapshot, persist, saveSnapshot, saveWarmSnapshot } from "./idb.ts"; import type { SnapshotImage, WarmSnapshotImage } from "../snapshot.ts"; @@ -68,6 +69,10 @@ async function readBody(res: Response, report: boolean): Promise { async function init(m: InitMessage) { config = m; + if (m.maxHeap) { + setMaxHeap(m.maxHeap); + if (typeof FinalizationRegistry !== "undefined") setAllocationCheck(MAX_HEAP, trackArrays(), { lazy: true }); + } const res = await fetch(new URL("boot.pack", m.runtime)); if (!res.ok) throw new Error(`cannot load ${res.url}: ${res.status}`); const [pack] = await Promise.all([readBody(res, m.progress).then(parsePack), persist(userFiles)]); @@ -194,6 +199,7 @@ function newVm(classpath: string[], vmId: number, library = false): JVM { stdin: readStdin, hostName: config!.hostName, debug: config!.debug, + afterBoot: resetAllocationCheck, env: config!.env, loadNativeLibrary, fileClosed, diff --git a/src/heapsize.ts b/src/heapsize.ts new file mode 100644 index 0000000..fea3e19 --- /dev/null +++ b/src/heapsize.ts @@ -0,0 +1,5 @@ +// a heap size: bytes, or k, m or g (NaN when malformed) +export function heapSize(s: string): number { + const unit = { k: 2 ** 10, m: 2 ** 20, g: 2 ** 30 }[s.slice(-1).toLowerCase()]; + return unit ? Number(s.slice(0, -1)) * unit : Number(s); +} diff --git a/src/interp.ts b/src/interp.ts index 5cd3dae..1144454 100644 --- a/src/interp.ts +++ b/src/interp.ts @@ -1,7 +1,7 @@ // Bytecode interpreter and the per-thread run loop. import { - INITIALIZED, JArray, JClass, JMethod, JObject, isWide, longToFloat, newArrayData, + INITIALIZED, JArray, JClass, JMethod, JObject, isWide, longToFloat, newArrayData, sched, } from "./runtime.ts"; import { Frame, JThread, JavaThrow, MAX_DIRECT, NativeFrame, PendingThrow, RUNNABLE, TERMINATED, WAITING, fromNative, @@ -515,6 +515,11 @@ export function interpret(t: JThread, f: Frame, budget: number): number { // Counts a taken backward branch; true when the method got compiled and the // run loop should switch to the compiled code at the branch target. function backedge(t: JThread, f: Frame, pc: number, sp: number): boolean { + if (sched.yieldNow) { + f.pc = pc; + f.sp = sp; + return true; + } const m = f.m; if (m.jit === null) { if (m.noJit) return false; @@ -822,7 +827,7 @@ function countEntry(t: JThread, f: Frame) { // Runs t for up to budget instructions. export function runThread(t: JThread, budget: number) { - while (budget > 0 && t.state === RUNNABLE) { + while (budget > 0 && t.state === RUNNABLE && !sched.yieldNow) { const f = t.top; if (f === undefined) { terminate(t); diff --git a/src/jvm.ts b/src/jvm.ts index 4e8359c..763668b 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -11,7 +11,7 @@ import { JImage } from "./jimage.ts"; import { ACC_ABSTRACT, ACC_FINAL, ACC_INTERFACE, ACC_NATIVE, ACC_PRIVATE, ACC_PROTECTED, ACC_PUBLIC, ACC_STATIC, ACC_SUPER, ACC_VARARGS, ERRONEOUS, INITIALIZED, INITIALIZING, JArray, JClass, JField, - JMethod, JObject, LINKED, Monitor, defaultValue, newArrayData, + JMethod, JObject, LINKED, Monitor, defaultValue, newArrayData, noteYield, sched, } from "./runtime.ts"; import type { CallSite, NativeFn } from "./runtime.ts"; import { @@ -974,23 +974,28 @@ export class JVM { private schedule() { if (this.scheduled || this.exitCode !== null) return; this.scheduled = true; - YIELD(() => this.loop()); + // over the heap cap the threads wait for a collection + if (sched.pause) setTimeout(() => this.loop(), 4); + else YIELD(() => this.loop()); } private loop() { this.scheduled = false; + noteYield(); const end = performance.now() + 20; while (this.runQueue.length > 0 && this.exitCode === null) { const t = this.runQueue.shift()!; this.queued.delete(t); if (t.state !== RUNNABLE) continue; + sched.thread = t; runThread(t, 20000); if (t.state === RUNNABLE && !this.queued.has(t)) { this.queued.add(t); this.runQueue.push(t); } - if (performance.now() > end) break; + if (performance.now() > end || sched.yieldNow) break; } + sched.thread = null; if (this.runQueue.length > 0) this.schedule(); } diff --git a/src/node.ts b/src/node.ts index 69a1efd..5e7fd59 100644 --- a/src/node.ts +++ b/src/node.ts @@ -18,6 +18,7 @@ import { ImageBackend, imageFd } from "./image.ts"; import { NodeNet } from "./net-node.ts"; import { processNatives } from "./process-node.ts"; import { PendingThrow } from "./thread.ts"; +import { heapSize } from "./heapsize.ts"; import { clearAllocationCheck, hasMaxHeap, MAX_HEAP, resetAllocationCheck, setAllocationCheck, setMaxHeap } from "./runtime.ts"; import type { NetBackend } from "./sockets.ts"; @@ -414,7 +415,7 @@ function nodeVm(o: NodeRunOptions): JVM { setAllocationCheck(MAX_HEAP, () => { const m = process.memoryUsage(); return m.heapUsed + m.arrayBuffers; - }, gc); + }, { gc }); } else clearAllocationCheck(); const bridge = new Bridge({ call: (_, req) => vm.library.request(req), @@ -529,12 +530,6 @@ export function moduleFlag(o: NodeRunOptions, arg: string, next: string | undefi return true; } -// a -Xmx size: bytes, or k, m or g -function heapSize(s: string): number { - const unit = { k: 2 ** 10, m: 2 ** 20, g: 2 ** 30 }[s.slice(-1).toLowerCase()]; - return unit ? Number(s.slice(0, -1)) * unit : Number(s); -} - // the java-web command, which dist/cli.js runs export function main(argv: string[]) { const o: NodeRunOptions = { classpath: ["."], props: {} }; diff --git a/src/runtime.ts b/src/runtime.ts index b3fe528..64de9f3 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -178,12 +178,29 @@ export function longToFloat(v: bigint): number { return Math.fround(Number(negative ? -rounded : rounded) * 2048); } +// Where the engine cannot measure typed-array memory: live bytes of the arrays +// that have a buffer of their own, counted from allocation to finalization. +const TRACK_MIN = 4096; +let tracked = 0; +let trackingArrays = false; +export function trackArrays(): () => number { + trackingArrays = true; + return () => tracked; +} + export class JArray extends JObject { a: ArrayData; constructor(cls: JClass, a: ArrayData) { super(cls, EMPTY); this.a = a; - if (allocationCheck?.enabled) allocated(ArrayBuffer.isView(a) ? a.byteLength : a.length * 8); + if (allocationCheck?.enabled) { + const bytes = ArrayBuffer.isView(a) ? a.byteLength : a.length * 8; + allocated(bytes); + if (trackingArrays && bytes >= TRACK_MIN) { + tracked += bytes; + finalizers().register(this, bytes); + } + } } } @@ -374,8 +391,9 @@ export function primName(c: string) { throw new Error(`not a primitive: ${c}`); } -// Reported by Runtime.maxMemory and the per-array limit. Node adds a sampled -// live-heap check when -Xmx is set; browser allocations keep the per-array cap. +// Reported by Runtime.maxMemory and the per-array limit. When a maximum is +// set, a sampled live-heap check also caps the whole heap (Node with -Xmx, +// browsers that can measure it). export let MAX_HEAP = 1 << 30; let explicitMaxHeap = false; export function setMaxHeap(bytes: number) { @@ -383,9 +401,15 @@ export function setMaxHeap(bytes: number) { explicitMaxHeap = true; } export function hasMaxHeap() { return explicitMaxHeap; } -let allocationCheck: { cap: number; step: number; allocated: number; baseline: number; enabled: boolean; over: boolean; usage: () => number; gc?: () => void } | null = null; -export function setAllocationCheck(cap: number, usage: () => number, gc?: () => void) { - allocationCheck = { cap, step: Math.min(cap, Math.max(4 << 20, cap / 16)), allocated: 0, baseline: 0, enabled: false, over: false, usage, gc }; +interface AllocationCheck { + cap: number; step: number; allocated: number; baseline: number; enabled: boolean; over: boolean; + usage: () => number; gc?: () => void; + // no gc(): the engine frees garbage between tasks, see lazyOver + lazy: boolean; +} +let allocationCheck: AllocationCheck | null = null; +export function setAllocationCheck(cap: number, usage: () => number, o: { gc?: () => void; lazy?: boolean } = {}) { + allocationCheck = { cap, step: Math.min(cap, Math.max(4 << 20, cap / 16)), allocated: 0, baseline: 0, enabled: false, over: false, usage, gc: o.gc, lazy: !!o.lazy }; } export function clearAllocationCheck() { allocationCheck = null; } export function resetAllocationCheck() { @@ -394,21 +418,75 @@ export function resetAllocationCheck() { allocationCheck.allocated = 0; allocationCheck.enabled = true; } + +// Browsers have no gc(). Arrays the program dropped keep counting until the +// engine collects them and runs their finalizers, which happens between tasks. +// Over the cap, an allocation of throwaway buffer size nudges the engine to +// collect, and the VM pauses its threads like a collection would. The error +// comes when the usage is still over after finalizers have stopped running +// for QUIET_MS, or after GIVE_UP_MS in any case. +export const sched: { thread: { slice: number } | null; yieldNow: boolean; pause: boolean } = { thread: null, yieldNow: false, pause: false }; +const NUDGE = 256 << 20, QUIET_MS = 100, GIVE_UP_MS = 1000; +let overSince = 0, nudgedAt = 0, lastFinalizedAt = 0; +// the scheduler returned to the event loop +export function noteYield() { + sched.yieldNow = false; + sched.pause = false; +} +let registry: FinalizationRegistry | null = null; +function finalizers() { + return registry ??= new FinalizationRegistry((bytes) => { + tracked -= bytes; + lastFinalizedAt = performance.now(); + }); +} + +function nudge(now: number) { + nudgedAt = now; + try { + new ArrayBuffer(NUDGE); + } catch {} +} + +function lazyOver(check: AllocationCheck) { + const now = performance.now(); + if (!check.over) { + check.over = true; + overSince = lastFinalizedAt = now; + nudge(now); + } else { + const quiet = now - Math.max(lastFinalizedAt, nudgedAt) >= QUIET_MS; + if (quiet || now - overSince >= GIVE_UP_MS) { + check.over = false; + throw new PendingThrow("java/lang/OutOfMemoryError", "Java heap space"); + } + if (now - nudgedAt >= QUIET_MS) nudge(now); + } + sched.yieldNow = sched.pause = true; + if (sched.thread) sched.thread.slice = 0; + check.allocated = check.step - 1; +} + function allocated(bytes: number) { const check = allocationCheck; if (!check?.enabled || bytes <= 0) return; check.allocated += bytes; - if (check.gc ? check.allocated < check.step : check.allocated <= check.cap) return; + if (check.allocated < (check.gc || check.lazy ? check.step : check.cap)) return; check.allocated = 0; if (check.usage() - check.baseline > check.cap) check.gc?.(); + const over = check.usage() - check.baseline > check.cap; + if (!over) { + check.over = false; + return; + } + if (check.lazy) return lazyOver(check); // node frees array buffers after a collection, not during it, so one reading // over the cap can still count garbage: throw on the second in a row - const over = check.usage() - check.baseline > check.cap; - if (over && check.over) { + if (check.over) { check.over = false; throw new PendingThrow("java/lang/OutOfMemoryError", "Java heap space"); } - check.over = over; + check.over = true; } const ELEM_SIZE: Record = { Z: 1, B: 1, C: 2, S: 2, I: 4, F: 4, J: 8, D: 8, L: 4 }; diff --git a/test/heapcheck.test.ts b/test/heapcheck.test.ts new file mode 100644 index 0000000..395efb0 --- /dev/null +++ b/test/heapcheck.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { clearAllocationCheck, JArray, resetAllocationCheck, sched, setAllocationCheck, trackArrays } from "../src/runtime.ts"; +import type { JClass } from "../src/runtime.ts"; +import { PendingThrow } from "../src/thread.ts"; + +const cls = {} as JClass; +const MB = 1 << 20; +const alloc = (mb: number) => new JArray(cls, new Int8Array(mb * MB)); +const oom = (f: () => void) => assert.throws(f, (e: unknown) => e instanceof PendingThrow && e.cls === "java/lang/OutOfMemoryError"); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +test("a lazy check pauses the scheduler when over, and throws only after waiting", async () => { + let used = 0; + setAllocationCheck(8 * MB, () => used, { lazy: true }); + resetAllocationCheck(); + try { + used = 100 * MB; + alloc(5); + assert.equal(sched.pause, true); + assert.equal(sched.yieldNow, true); + alloc(1); + alloc(1); + await wait(170); + oom(() => alloc(1)); + used = 0; + alloc(1); + alloc(5); + } finally { + clearAllocationCheck(); + } +}); + +test("a lazy check recovers when the memory was freed meanwhile", async () => { + let used = 0; + setAllocationCheck(8 * MB, () => used, { lazy: true }); + resetAllocationCheck(); + try { + used = 100 * MB; + alloc(5); + await wait(170); + used = 0; + alloc(1); + used = 100 * MB; + alloc(1); + alloc(1); + } finally { + clearAllocationCheck(); + } +}); + +test("tracked arrays count live bytes of the large ones only", () => { + const live = trackArrays(); + setAllocationCheck(64 * MB, live, { lazy: true }); + resetAllocationCheck(); + try { + const before = live(); + const keep = [alloc(1), alloc(2)]; + new JArray(cls, new Int8Array(100)); + assert.equal(live() - before, 3 * MB); + assert.equal(keep.length, 2); + } finally { + clearAllocationCheck(); + } +});