Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 6 additions & 1 deletion src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -153,6 +156,8 @@ export function cheerpjInit(options: InitOptions = {}): Promise<void> {
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<FromWorker>) => {
Expand Down Expand Up @@ -182,7 +187,7 @@ export function cheerpjInit(options: InitOptions = {}): Promise<void> {
};
});
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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/browser/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/browser/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,6 +69,10 @@ async function readBody(res: Response, report: boolean): Promise<ArrayBuffer> {

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)]);
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/heapsize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 7 additions & 2 deletions src/interp.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions src/jvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}

Expand Down
9 changes: 2 additions & 7 deletions src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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: {} };
Expand Down
98 changes: 88 additions & 10 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}

Expand Down Expand Up @@ -374,18 +391,25 @@ 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) {
MAX_HEAP = Math.min(bytes, 1 << 30);
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() {
Expand All @@ -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<number> | null = null;
function finalizers() {
return registry ??= new FinalizationRegistry<number>((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<string, number> = { Z: 1, B: 1, C: 2, S: 2, I: 4, F: 4, J: 8, D: 8, L: 4 };

Expand Down
Loading
Loading