From c086197e69dd7ba380fc39c3c3d2bf30fe27e33a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:33:19 -0600 Subject: [PATCH 1/3] Honor -Xmx under the java-web command as the largest allocation and Runtime.maxMemory. --- docs/node.md | 1 + src/node.ts | 8 ++++++++ src/runtime.ts | 8 ++++++-- test/heap/Heap.java | 13 +++++++++++++ test/programs.test.ts | 9 +++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 test/heap/Heap.java diff --git a/docs/node.md b/docs/node.md index 73faa60..86313aa 100644 --- a/docs/node.md +++ b/docs/node.md @@ -19,6 +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 largest array, and what `Runtime.maxMemory()` reports (default and at most 1g); the total heap is not counted | | `--max-rss MB` | exit when resident memory passes this (default 2048) | | `--jdk DIR` | an exploded JDK image instead of the packaged one | | `--debug` | log VM events | diff --git a/src/node.ts b/src/node.ts index 39099d3..5c16205 100644 --- a/src/node.ts +++ b/src/node.ts @@ -15,6 +15,7 @@ import { hostNatives } from "./hostnatives.ts"; import { ImageBackend } from "./image.ts"; import { NodeNet } from "./net-node.ts"; import { PendingThrow } from "./thread.ts"; +import { setMaxHeap } from "./runtime.ts"; import type { NetBackend } from "./sockets.ts"; // A host directory exposed read-only (or writable) inside the VFS. @@ -314,6 +315,12 @@ 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); +} + function main(argv: string[]) { const o: NodeRunOptions = { classpath: ["."], props: {} }; let i = 0; @@ -329,6 +336,7 @@ function main(argv: string[]) { else if (isAssertionFlag(a)) (o.assertions ??= []).push(a); else if (a === "--enable-preview") o.enablePreview = true; else if (a === "--locale-data") o.localeData = true; + else if (/^-Xmx\d+[kmg]?$/i.test(a)) setMaxHeap(heapSize(a.slice(4))); else if (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue; else if (a === "--jdk") o.jdk = argv[++i]; else if (a === "--max-rss") o.maxRssMb = Number(argv[++i]); diff --git a/src/runtime.ts b/src/runtime.ts index dfd6850..38f40f5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -359,8 +359,12 @@ export function primName(c: string) { throw new Error(`not a primitive: ${c}`); } -// Reported by Runtime.maxMemory; no array may be bigger. -export const MAX_HEAP = 1 << 30; +// Reported by Runtime.maxMemory; no array may be bigger. -Xmx lowers it; the +// total of live objects is not counted. +export let MAX_HEAP = 1 << 30; +export function setMaxHeap(bytes: number) { + MAX_HEAP = Math.min(bytes, 1 << 30); +} const ELEM_SIZE: Record = { Z: 1, B: 1, C: 2, S: 2, I: 4, F: 4, J: 8, D: 8, L: 4 }; // V8 gives a typed array over 64 bytes its own malloc'd store, about ten diff --git a/test/heap/Heap.java b/test/heap/Heap.java new file mode 100644 index 0000000..68cda44 --- /dev/null +++ b/test/heap/Heap.java @@ -0,0 +1,13 @@ +// Run with -Xmx16m: an array bigger than the heap fails, a small one does not. +public class Heap { + public static void main(String[] args) { + try { + int[] big = new int[8 << 20]; + System.out.println("allocated " + big.length); + } catch (OutOfMemoryError e) { + System.out.println("OutOfMemoryError: " + e.getMessage()); + } + System.out.println("small " + new int[1 << 20].length); + System.out.println("max under 32 MB " + (Runtime.getRuntime().maxMemory() <= 32 << 20)); + } +} diff --git a/test/programs.test.ts b/test/programs.test.ts index 54dcc2d..2804217 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -64,6 +64,15 @@ test("System.gc collects under the command", { skip: !files.includes("Refs.java" assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); }); +test("-Xmx caps an allocation under the command", () => { + const classes = join(root, "build", "test-heap"); + execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "heap", "Heap.java")]); + const args = ["-Xmx16m", "-cp", classes, "Heap"]; + const ref = spawnSync(hotspot("java"), args, { encoding: "utf8" }); + const got = spawnSync(process.execPath, [join(root, "src", "node.ts"), ...args], { encoding: "utf8" }); + assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); +}); + test("stdout closed by its reader", async () => { const classes = join(root, "build", "test-pipe"); execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "pipe", "Pipe.java")]); From fb36dd6d3beda9e7497d7c41461a983baf0790d2 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:38:20 -0600 Subject: [PATCH 2/3] Refuse a heap under 2 MB and round -Xmx up to 2 MB steps, as x64 HotSpot does. --- docs/node.md | 2 +- src/node.ts | 10 +++++++++- test/heap/Heap.java | 7 +++++-- test/programs.test.ts | 5 ++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/node.md b/docs/node.md index 86313aa..d1b7bb7 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 largest array, and what `Runtime.maxMemory()` reports (default and at most 1g); the total heap is not counted | +| `-Xmx` | the largest array, and what `Runtime.maxMemory()` reports (default and at most 1g, rounded up to 2 MB steps, below 2m an error); the total heap is not counted | | `--max-rss MB` | exit when resident memory passes this (default 2048) | | `--jdk DIR` | an exploded JDK image instead of the packaged one | | `--debug` | log VM events | diff --git a/src/node.ts b/src/node.ts index 5c16205..e077c6c 100644 --- a/src/node.ts +++ b/src/node.ts @@ -336,7 +336,15 @@ function main(argv: string[]) { else if (isAssertionFlag(a)) (o.assertions ??= []).push(a); else if (a === "--enable-preview") o.enablePreview = true; else if (a === "--locale-data") o.localeData = true; - else if (/^-Xmx\d+[kmg]?$/i.test(a)) setMaxHeap(heapSize(a.slice(4))); + else if (/^-Xmx\d+[kmg]?$/i.test(a)) { + // x64 HotSpot refuses to start below 2 MB and rounds up to 2 MB steps + const n = heapSize(a.slice(4)); + if (n < 2 ** 21) { + console.error(n === 0 ? `Invalid maximum heap size: ${a}` : "Error occurred during initialization of VM\nToo small maximum heap"); + process.exit(1); + } + setMaxHeap(Math.ceil(n / 2 ** 21) * 2 ** 21); + } else if (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue; else if (a === "--jdk") o.jdk = argv[++i]; else if (a === "--max-rss") o.maxRssMb = Number(argv[++i]); diff --git a/test/heap/Heap.java b/test/heap/Heap.java index 68cda44..ddf31ce 100644 --- a/test/heap/Heap.java +++ b/test/heap/Heap.java @@ -1,4 +1,5 @@ -// Run with -Xmx16m: an array bigger than the heap fails, a small one does not. +// Run with -Xmx17m: an array bigger than the heap fails, a small one does not. +// With an argument, prints the heap's size. public class Heap { public static void main(String[] args) { try { @@ -8,6 +9,8 @@ public static void main(String[] args) { System.out.println("OutOfMemoryError: " + e.getMessage()); } System.out.println("small " + new int[1 << 20].length); - System.out.println("max under 32 MB " + (Runtime.getRuntime().maxMemory() <= 32 << 20)); + // the heap's exact size depends on the platform's rounding + long max = Runtime.getRuntime().maxMemory(); + System.out.println(args.length > 0 ? "max " + max : "max under 32 MB " + (max <= 32 << 20)); } } diff --git a/test/programs.test.ts b/test/programs.test.ts index 2804217..d092ae3 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -67,10 +67,13 @@ test("System.gc collects under the command", { skip: !files.includes("Refs.java" test("-Xmx caps an allocation under the command", () => { const classes = join(root, "build", "test-heap"); execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "heap", "Heap.java")]); - const args = ["-Xmx16m", "-cp", classes, "Heap"]; + const args = ["-Xmx17m", "-cp", classes, "Heap"]; const ref = spawnSync(hotspot("java"), args, { encoding: "utf8" }); const got = spawnSync(process.execPath, [join(root, "src", "node.ts"), ...args], { encoding: "utf8" }); assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); + // x64 HotSpot rounds the heap up to 2 MB steps + const max = spawnSync(process.execPath, [join(root, "src", "node.ts"), ...args, "max"], { encoding: "utf8" }); + assert.match(max.stdout, /^max 18874368$/m); }); test("stdout closed by its reader", async () => { From c6a526e2e9a1fc40c8e38b5e355d51a9b51f1edb Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:22:20 -0600 Subject: [PATCH 3/3] Reject malformed -Xmx values and keep Runtime and heap MXBean values within the limit. --- src/natives/lang.ts | 5 +++-- src/natives/management.ts | 11 ++++++++--- src/node.ts | 7 ++++--- test/heap/Heap.java | 6 +++++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/natives/lang.ts b/src/natives/lang.ts index 0df48ba..d47c054 100644 --- a/src/natives/lang.ts +++ b/src/natives/lang.ts @@ -315,8 +315,9 @@ export const natives: Natives = { // Runtime "java/lang/Runtime.availableProcessors()I": () => 1, - "java/lang/Runtime.freeMemory()J": () => 256n << 20n, - "java/lang/Runtime.totalMemory()J": () => 512n << 20n, + // free <= total <= max, as callers that compute available memory expect + "java/lang/Runtime.freeMemory()J": () => BigInt(Math.min(512 << 20, MAX_HEAP) / 2), + "java/lang/Runtime.totalMemory()J": () => BigInt(Math.min(512 << 20, MAX_HEAP)), "java/lang/Runtime.maxMemory()J": () => BigInt(MAX_HEAP), // Collects where the engine allows it (node --expose-gc), in a new JS job: // a WeakRef read earlier in a job keeps its target alive until the job ends. diff --git a/src/natives/management.ts b/src/natives/management.ts index 0bd5a70..3f4acfc 100644 --- a/src/natives/management.ts +++ b/src/natives/management.ts @@ -91,8 +91,14 @@ function* poolUsage(t: JThread, self: JObject): NativeGen { const b: Beans = yield* beans(t); const pool = POOLS[b.pools.indexOf(self)]; if (pool.name !== OLD) return yield* usage(t, 0, 0, 0, -1); + return yield* heapUsage(t); +} + +// MemoryUsage needs used <= committed <= max; node's heap does not know -Xmx +function* heapUsage(t: JThread): NativeGen { const h = jsHeap(); - return yield* usage(t, 0, h.used, Math.max(h.used, h.committed), MAX_HEAP); + const committed = Math.min(Math.max(h.used, h.committed), MAX_HEAP); + return yield* usage(t, 0, Math.min(h.used, committed), committed, MAX_HEAP); } function liveThreads(vm: JVM): JThread[] { @@ -146,8 +152,7 @@ export const natives: Natives = { }, "sun/management/MemoryImpl.getMemoryUsage0(Z)Ljava/lang/management/MemoryUsage;": function* (t, [, heap]): NativeGen { if (!heap) return yield* usage(t, 0, 0, 0, -1); - const h = jsHeap(); - return yield* usage(t, 0, h.used, Math.max(h.used, h.committed), MAX_HEAP); + return yield* heapUsage(t); }, "sun/management/MemoryImpl.setVerboseGC(Z)V": () => undefined, diff --git a/src/node.ts b/src/node.ts index e077c6c..67a84b0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -336,11 +336,12 @@ function main(argv: string[]) { else if (isAssertionFlag(a)) (o.assertions ??= []).push(a); else if (a === "--enable-preview") o.enablePreview = true; else if (a === "--locale-data") o.localeData = true; - else if (/^-Xmx\d+[kmg]?$/i.test(a)) { + else if (a.startsWith("-Xmx")) { // x64 HotSpot refuses to start below 2 MB and rounds up to 2 MB steps - const n = heapSize(a.slice(4)); + const n = /^-Xmx\d+[kmg]?$/i.test(a) ? heapSize(a.slice(4)) : 0; if (n < 2 ** 21) { - console.error(n === 0 ? `Invalid maximum heap size: ${a}` : "Error occurred during initialization of VM\nToo small maximum heap"); + console.error(n === 0 ? `Invalid maximum heap size: ${a}\nError: Could not create the Java Virtual Machine.\nError: A fatal exception has occurred. Program will exit.` + : "Error occurred during initialization of VM\nToo small maximum heap"); process.exit(1); } setMaxHeap(Math.ceil(n / 2 ** 21) * 2 ** 21); diff --git a/test/heap/Heap.java b/test/heap/Heap.java index ddf31ce..3d01e9f 100644 --- a/test/heap/Heap.java +++ b/test/heap/Heap.java @@ -9,8 +9,12 @@ public static void main(String[] args) { System.out.println("OutOfMemoryError: " + e.getMessage()); } System.out.println("small " + new int[1 << 20].length); + Runtime rt = Runtime.getRuntime(); + System.out.println("free <= total <= max " + (rt.freeMemory() <= rt.totalMemory() && rt.totalMemory() <= rt.maxMemory())); + var usage = java.lang.management.ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + System.out.println("heap usage within max " + (usage.getCommitted() <= usage.getMax() && usage.getMax() == rt.maxMemory())); // the heap's exact size depends on the platform's rounding - long max = Runtime.getRuntime().maxMemory(); + long max = rt.maxMemory(); System.out.println(args.length > 0 ? "max " + max : "max under 32 MB " + (max <= 32 << 20)); } }