From 81eb97dd968600489f90702078f0cf76cd383568 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:29 -0600 Subject: [PATCH 01/15] Read the linked JDK image through range requests. --- AGENTS.md | 11 ++---- docs/development.md | 3 +- docs/node.md | 2 +- scripts/ExplodedModuleInfo.java | 4 +- scripts/LinuxImage.java | 28 +------------ scripts/ResourceImage.java | 59 ---------------------------- scripts/UndoJlink.java | 22 +++++++++++ scripts/build-jdk.sh | 39 ++++++++----------- scripts/build-web.ts | 2 + src/browser/worker.ts | 1 + src/fs.ts | 3 +- src/jimage.ts | 69 +++++++++++++++++++++++++++++++++ src/jvm.ts | 20 +++++++--- src/natives/misc.ts | 22 ++--------- src/node.ts | 20 +++++++++- test/java/JrtUrls.java | 4 ++ test/jimage.test.ts | 16 ++++++++ test/programs.test.ts | 5 ++- 18 files changed, 181 insertions(+), 149 deletions(-) delete mode 100644 scripts/ResourceImage.java create mode 100644 scripts/UndoJlink.java create mode 100644 src/jimage.ts create mode 100644 test/jimage.test.ts diff --git a/AGENTS.md b/AGENTS.md index 639178d..9c2252b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Node, which is how the tests run. - `src/classfile.ts` class file parser - `src/runtime.ts` classes, methods, fields, objects, arrays - `src/interp.ts` bytecode interpreter and per-thread run loop -- `src/jvm.ts` loading, linking, initialization, resolution, scheduler, boot +- `src/jvm.ts` loading, linking, initialization, resolution, scheduler, boot; `src/jimage.ts` reads the linked JDK image - `src/indy.ts` invokedynamic, method handles - `src/snapshot.ts` boot snapshots: saving and restoring the VM as it is after boot - `src/library.ts` library mode in the VM: object handles, calls, field access, @@ -76,12 +76,9 @@ Node, which is how the tests run. ## Glossary -- **java.home image**: `build/jdk`, the Linux x64 JDK's class library in the - exploded layout (`modules//...` instead of a `lib/modules` jimage), - built the same on every host. Mounted at - `/jdk` in the VFS. It has no `lib/modules`, which would switch the JDK to - image mode; `lib/resources.jimage` holds only the non-class resources, for - `jrt:` URLs. +- **java.home image**: `build/jdk`, a linked image with the Linux x64 JDK's + class library in `lib/modules`, built the same on every host and mounted at + `/jdk` in the VFS. - **VFS**: the virtual file system the Java side sees (`src/fs.ts`). - **native**: a JS function implementing a Java `native` method, keyed by `class.name(desc)`. A plain native returns its result; a generator native diff --git a/docs/development.md b/docs/development.md index e58b53c..8c1cdee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,8 @@ same download on Linux x64) into `~/.cache/java-web`, and checks each SHA-256. The runtime image comes from the Linux x64 JDK, so every machine, a Mac included, builds the same image, with the Linux class library whose natives java-web implements. The host JDK is the HotSpot reference the tests run, linked -at `build/hotspot`. +at `build/hotspot`. The build links the selected modules into `build/jdk/lib/modules`; +`lib/packages.txt` indexes packages for the boot class loader. ```sh pnpm install diff --git a/docs/node.md b/docs/node.md index eae523f..3246dfd 100644 --- a/docs/node.md +++ b/docs/node.md @@ -21,7 +21,7 @@ npx @project516/java-web [options] -jar app.jar [args] | `--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, 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 | +| `--jdk DIR` | a java-web JDK image instead of the packaged one | | `--debug` | log VM events | Other `-X` and HotSpot-only options are ignored. diff --git a/scripts/ExplodedModuleInfo.java b/scripts/ExplodedModuleInfo.java index 2daa38e..2572abb 100644 --- a/scripts/ExplodedModuleInfo.java +++ b/scripts/ExplodedModuleInfo.java @@ -5,9 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; -// Drops ModuleHashes from module-info.class files, as a JDK exploded build -// has none: exploded modules cannot be hashed, and boot fails when java.base -// records hashes of other modules. +// Drop hashes of the original JDK modules before linking a different set. void main(String[] args) throws Exception { ClassFile cf = ClassFile.of(); for (String a : args) { diff --git a/scripts/LinuxImage.java b/scripts/LinuxImage.java index 6b6f7c2..f189aa9 100644 --- a/scripts/LinuxImage.java +++ b/scripts/LinuxImage.java @@ -1,4 +1,3 @@ -import java.io.DataInputStream; import java.io.IOException; import java.lang.module.ModuleDescriptor; import java.lang.module.ModuleDescriptor.Requires; @@ -14,9 +13,7 @@ import java.util.Set; import java.util.TreeSet; -// Builds the java.home image from a Linux JDK without jlink: keeps the root -// modules and the modules they require, as jlink does without --bind-services, -// and copies the conf/, lib/ and legal/ files of those modules. +// Copies the conf/, lib/ and legal/ files of the linked modules. // Args: void main(String[] args) throws IOException { Path jdk = Path.of(args[0]), src = Path.of(args[1]), out = Path.of(args[3]); @@ -36,7 +33,6 @@ void main(String[] args) throws IOException { // Each module's files are listed in jdk.jlink as type|kind|hash or link target|path Path lists = src.resolve("jdk.jlink/jdk/tools/jlink/internal/runtimelink"); for (String name : keep) { - Files.move(src.resolve(name), out.resolve("modules").resolve(name)); Path list = lists.resolve("fs_" + name + "_files"); if (Files.notExists(list)) continue; for (String line : Files.readAllLines(list)) { @@ -54,30 +50,8 @@ void main(String[] args) throws IOException { } } - restoreSystemModulesMap(src, out); String version = Files.readAllLines(jdk.resolve("release")).stream() .filter(l -> l.startsWith("JAVA_VERSION=")).findFirst().orElseThrow(); Files.writeString(out.resolve("release"), version + "\nMODULES=\"" + String.join(" ", keep) + "\"\n"); } - -// jlink's system-modules plugin generated SystemModules classes for the whole -// JDK and pointed SystemModulesMap at them. The image has no lib/modules, so -// the JDK must find its modules by scanning, which the original map allows. -// diff_java.base holds the original bytes for undoing the plugin. -void restoreSystemModulesMap(Path src, Path out) throws IOException { - Path diff = src.resolve("jdk.jlink/jdk/tools/jlink/internal/runtimelink/diff_java.base"); - String dir = "/java.base/jdk/internal/module/"; - try (var in = new DataInputStream(Files.newInputStream(diff))) { - in.readInt(); - for (int n = in.readInt(); n > 0; n--) { - short kind = in.readShort(); - String name = new String(in.readNBytes(in.readInt())); - byte[] original = in.readNBytes(in.readInt()); - if (!name.startsWith(dir)) continue; - Path file = out.resolve("modules").resolve(name.substring(1)); - if (kind == 1) Files.delete(file); - else Files.write(file, original); - } - } -} diff --git a/scripts/ResourceImage.java b/scripts/ResourceImage.java deleted file mode 100644 index 1629fda..0000000 --- a/scripts/ResourceImage.java +++ /dev/null @@ -1,59 +0,0 @@ -import java.io.IOException; -import java.lang.classfile.Attributes; -import java.lang.classfile.ClassFile; -import java.lang.classfile.attribute.ModuleAttribute; -import java.lang.constant.ModuleDesc; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.spi.ToolProvider; -import java.util.stream.Stream; - -// Builds a jimage that holds only the non-class resources of an exploded -// modules/ directory, which is all jrt: URLs need. jlink insists that a -// module's packages match its resources, so each module goes in as a stub -// module-info plus its resources, with the target platform of the image. -// Args: -void main(String[] args) throws Exception { - Path modules = Path.of(args[0]), work = Path.of(args[1]), image = Path.of(args[2]); - Path jmods = work.resolve("jmods"); - var javaBase = ClassFile.of().parse(modules.resolve("java.base/module-info.class")); - String platform = javaBase.findAttribute(Attributes.moduleTarget()).orElseThrow().targetPlatform().stringValue(); - Files.createDirectories(jmods); - List names = new ArrayList<>(); - try (Stream dirs = Files.list(modules)) { - for (Path dir : dirs.sorted().toList()) { - String name = dir.getFileName().toString(); - Path classes = work.resolve("classes").resolve(name); - int copied = 0; - try (Stream files = Files.walk(dir)) { - for (Path f : files.filter(Files::isRegularFile).toList()) { - if (f.toString().endsWith(".class")) continue; - Path to = classes.resolve(dir.relativize(f).toString()); - Files.createDirectories(to.getParent()); - Files.copy(f, to); - copied++; - } - } - if (copied == 0) continue; - ModuleAttribute module = ModuleAttribute.of(ModuleDesc.of(name), b -> { - b.moduleVersion(Runtime.version().toString()); - if (!name.equals("java.base")) b.requires(ModuleDesc.of("java.base"), ClassFile.ACC_MANDATED, null); - }); - Files.write(classes.resolve("module-info.class"), ClassFile.of().buildModule(module)); - run("jmod", "create", "--target-platform", platform, "--class-path", classes.toString(), jmods.resolve(name + ".jmod").toString()); - names.add(name); - } - } - Path linked = work.resolve("image"); - run("jlink", "--module-path", jmods.toString(), "--add-modules", String.join(",", names), - "--output", linked.toString(), "--disable-plugin", "system-modules", "--disable-plugin", "generate-jli-classes"); - Files.createDirectories(image.getParent()); - Files.move(linked.resolve("lib/modules"), image, java.nio.file.StandardCopyOption.REPLACE_EXISTING); -} - -void run(String tool, String... args) throws IOException { - int rc = ToolProvider.findFirst(tool).orElseThrow().run(System.out, System.err, args); - if (rc != 0) throw new IOException(tool + " failed with exit code " + rc); -} diff --git a/scripts/UndoJlink.java b/scripts/UndoJlink.java new file mode 100644 index 0000000..8eca5dc --- /dev/null +++ b/scripts/UndoJlink.java @@ -0,0 +1,22 @@ +import java.io.DataInputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +void main(String[] args) throws Exception { + Path src = Path.of(args[0]); + Path diff = src.resolve("jdk.jlink/jdk/tools/jlink/internal/runtimelink/diff_java.base"); + try (var in = new DataInputStream(Files.newInputStream(diff))) { + in.readInt(); + for (int n = in.readInt(); n > 0; n--) { + short kind = in.readShort(); + String name = new String(in.readNBytes(in.readInt())); + byte[] original = in.readNBytes(in.readInt()); + Path file = src.resolve(name.substring(1)); + if (kind == 1) Files.deleteIfExists(file); + else { + Files.createDirectories(file.getParent()); + Files.write(file, original); + } + } + } +} diff --git a/scripts/build-jdk.sh b/scripts/build-jdk.sh index 4a82525..e609a7a 100644 --- a/scripts/build-jdk.sh +++ b/scripts/build-jdk.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# Builds build/jdk, the java.home the JVM runs on: the Linux x64 JDK 25 class -# library in the exploded layout (modules//...) instead of a -# lib/modules jimage, so classes and resources are plain files. +# Builds a linked Linux x64 java.home for the VM on every host. set -euo pipefail cd "$(dirname "$0")/.." @@ -51,12 +49,12 @@ mkdir -p build ln -sfn "$JAVA_HOME" build/hotspot # The image comes from the Linux x64 JDK on every host, since the natives are -# the Linux ones. Temurin ships no jmods, so jlink cannot link it: the modules -# are extracted from its lib/modules and selected by scripts/LinuxImage.java. +# the Linux ones. Extract its modules for jlink and restore their original +# contents before linking. linux=$(jdk_home x64_linux) src=build/jdk-src -rm -rf "$src" "$out" -mkdir -p "$src" "$out/modules" +rm -rf "$src" "$out" build/jdk-linked +mkdir -p "$src" "$out" "$JAVA_HOME/bin/jimage" extract --dir "$src" "$linux/lib/modules" # javaweb.tls is java-web's own TLS pass-through provider (scripts/tls), built @@ -65,18 +63,18 @@ mkdir -p "$src" "$out/modules" "$JAVA_HOME/bin/javac" -d "$src/javaweb.tls" $(find scripts/tls -name '*.java') "$JAVA_HOME/bin/java" scripts/LinuxImage.java "$linux" "$src" "$modules" "$out" -rm -rf "$src" - -# Without the generated SystemModules classes the JDK finds its modules by -# reading module-info.class files from the exploded tree. -"$JAVA_HOME/bin/java" scripts/ExplodedModuleInfo.java "$out"/modules/*/module-info.class +"$JAVA_HOME/bin/java" scripts/UndoJlink.java "$src" +"$JAVA_HOME/bin/java" scripts/ExplodedModuleInfo.java "$src"/*/module-info.class +"$JAVA_HOME/bin/jlink" --module-path "$src" --add-modules "$modules" --output build/jdk-linked +mkdir -p "$out/lib" +cp build/jdk-linked/lib/modules "$out/lib/modules" +rm -rf build/jdk-linked -# jrt: URLs read their resources through the jimage reader. Classes stay in -# modules/, so the image holds only the other resources. It is not named -# lib/modules, which would switch the JDK to image mode. -work=$(mktemp -d "$PWD/build/work.XXXXXX") -"$JAVA_HOME/bin/java" scripts/ResourceImage.java "$out/modules" "$work" "$out/lib/resources.jimage" -rm -rf "$work" +# Index the packages in the linked modules. +(cd "$src" && export LC_ALL=C && for m in $(sed -n 's/^MODULES="\(.*\)"/\1/p' "$OLDPWD/$out/release"); do + find "$m" -mindepth 2 -name '*.class' | sed 's|/[^/]*$||' | sort -u | sed "s|^$m/||;s|^|$m |" +done) > "$out/lib/packages.txt" +rm -rf "$src" # The same provider list with JavaWebTLS first, for java.security.properties sec=$out/conf/security @@ -110,11 +108,6 @@ mkdir -p "$out/lib/fonts" cp "$font_home/ttf/DejaVuSans.ttf" "$out/lib/fonts/" cp "$font_home/LICENSE" "$out/legal/java.desktop/dejavu-fonts.txt" -# module -> packages index, so the boot loader can find a class without probing. -(cd "$out/modules" && export LC_ALL=C && for m in *; do - find "$m" -mindepth 2 -name '*.class' | sed 's|/[^/]*$||' | sort -u | sed "s|^$m/||;s|^|$m |" -done) > "$out/lib/packages.txt" - # a digest of the whole image, which boot snapshots are made for (cd "$out" && node -e ' const { createHash } = require("node:crypto"); diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 43cb66b..b1b5698 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -85,6 +85,8 @@ class Recorder implements Backend { constructor(inner: Backend) { this.inner = inner; } stat(p: string) { return this.inner.stat(p); } list(p: string) { return this.inner.list(p); } + canReadRange(p: string) { return this.inner.canReadRange?.(p) ?? !!this.inner.readRange; } + readRange(p: string, off: number, len: number) { return this.inner.readRange?.(p, off, len) ?? null; } read(p: string) { const d = this.inner.read(p); if (d) this.seen.add(p); diff --git a/src/browser/worker.ts b/src/browser/worker.ts index 335cde4..7d4f12c 100644 --- a/src/browser/worker.ts +++ b/src/browser/worker.ts @@ -167,6 +167,7 @@ function newVm(classpath: string[], vmId: number): JVM { // javaProperties can set user.dir, which is then the working directory cwd: config!.props["user.dir"] ?? "/files", // System.loadLibrary would warn about native access, but its libraries are the page's JS modules + localeData: config!.localeData, props: { "java.io.tmpdir": "/tmp", "user.home": "/files", "jdk.module.enable.native.access.0": "ALL-UNNAMED", ...config!.props }, stdout: (data) => post({ type: "out", fd: 1, data }, [data.buffer as ArrayBuffer]), stderr: (data) => post({ type: "out", fd: 2, data }, [data.buffer as ArrayBuffer]), diff --git a/src/fs.ts b/src/fs.ts index 3b8d26f..9f65408 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -22,6 +22,7 @@ export interface Backend { read(path: string): Uint8Array | null; // bytes [off, off + len) of a file, for backends that can read part of one readRange?(path: string, off: number, len: number): Uint8Array | null; + canReadRange?(path: string): boolean; list(path: string): string[] | null; write?(path: string, data: Uint8Array): boolean; // the errno code (such as "ENOSPC") of the last failed write to path @@ -133,7 +134,7 @@ export class VFS { // Whether part of a file can be read without reading all of it. canReadRange(path: string): boolean { const r = this.resolve(path); - return !!r && !!r[0].readRange; + return !!r && !!r[0].readRange && (r[0].canReadRange?.(r[1]) ?? true); } readRange(path: string, off: number, len: number): Uint8Array | null { diff --git a/src/jimage.ts b/src/jimage.ts new file mode 100644 index 0000000..0edddf8 --- /dev/null +++ b/src/jimage.ts @@ -0,0 +1,69 @@ +import type { VFS } from "./fs.ts"; + +// Uncompressed jlink image. Only the index is kept in memory. +export class JImage { + private readonly index: Uint8Array; + private readonly view: DataView; + private readonly count: number; + private readonly locations: number; + private readonly strings: number; + + private readonly fs: VFS; + private readonly path: string; + + constructor(fs: VFS, path: string) { + this.fs = fs; + this.path = path; + const header = fs.readRange(path, 0, 28); + if (!header || header.length !== 28) throw new Error(`cannot read image header: ${path}`); + const h = new DataView(header.buffer, header.byteOffset, header.byteLength); + if (h.getUint32(0, true) !== 0xcafedada || h.getUint32(4, true) !== 0x10000) throw new Error(`invalid image: ${path}`); + this.count = h.getUint32(16, true); + this.locations = 28 + this.count * 8; + this.strings = this.locations + h.getUint32(20, true); + const size = this.strings + h.getUint32(24, true); + const index = fs.readRange(path, 0, size); + if (!index || index.length !== size) throw new Error(`cannot read image index: ${path}`); + this.index = index; + this.view = new DataView(index.buffer, index.byteOffset, index.byteLength); + } + + read(module: string, name: string): Uint8Array | null { + const full = `/${module}/${name}`; + const hash = (seed: number) => { + for (let i = 0; i < full.length; i++) { + const c = full.charCodeAt(i); + const bytes = c === 0 ? [0xc0, 0x80] : c < 0x80 ? [c] : c < 0x800 ? [0xc0 | (c >> 6), 0x80 | (c & 63)] : [0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)]; + for (const b of bytes) seed = Math.imul(seed, 0x01000193) ^ b; + } + return (seed & 0x7fffffff) % this.count; + }; + const redirect = this.view.getInt32(28 + hash(0x01000193) * 4, true); + if (!redirect) return null; + const slot = redirect < 0 ? -redirect - 1 : hash(redirect); + let p = this.locations + this.view.getUint32(28 + this.count * 4 + slot * 4, true); + const attrs = Array(8).fill(0); + while (this.index[p] > 7) { + const b = this.index[p++]; + let value = 0; + for (let n = (b & 7) + 1; n > 0; n--) value = value * 256 + this.index[p++]; + attrs[b >> 3] = value; + } + const str = (off: number) => { + if (!off) return ""; + const start = this.strings + off; + let value = ""; + for (let i = start; this.index[i]; i++) { + const b = this.index[i]; + if (b < 0x80) value += String.fromCharCode(b); + else if (b < 0xe0) value += String.fromCharCode(((b & 31) << 6) | (this.index[++i] & 63)); + else value += String.fromCharCode(((b & 15) << 12) | ((this.index[++i] & 63) << 6) | (this.index[++i] & 63)); + } + return value; + }; + const actual = `/${str(attrs[1])}/${attrs[2] ? str(attrs[2]) + "/" : ""}${str(attrs[3])}${attrs[4] ? "." + str(attrs[4]) : ""}`; + if (actual !== full) return null; + if (attrs[6]) throw new Error("compressed jimage resource"); + return this.fs.readRange(this.path, this.index.length + attrs[5], attrs[7]); + } +} diff --git a/src/jvm.ts b/src/jvm.ts index 6b2f6c3..fdb0d23 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -7,6 +7,7 @@ import type { ClassFile } from "./classfile.ts"; import { deliver, raise, runThread } from "./interp.ts"; import { NativeMemory } from "./memory.ts"; import type { VFS } from "./fs.ts"; +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, @@ -42,6 +43,7 @@ export interface JvmOptions { // VFS path of java.home (the output of scripts/build-jdk.sh) javaHome: string; classpath?: string[]; + localeData?: boolean; props?: Record; stdout: (b: Uint8Array) => void; stderr: (b: Uint8Array) => void; @@ -109,6 +111,7 @@ export class JVM { readonly fs: VFS; readonly mem = new NativeMemory(); readonly boot = new Map(); + private readonly image: JImage | null; // package -> module, for boot classes readonly bootPackages = new Map(); // Module objects defined to the boot loader, by name @@ -165,6 +168,8 @@ export class JVM { } this.exited = new Promise((r) => (this.exitResolve = r)); for (const [k, fn] of Object.entries(builtinNatives)) this.natives.set(k, fn); + const imagePath = `${opts.javaHome}/lib/modules`; + this.image = this.fs.stat(imagePath) ? new JImage(this.fs, imagePath) : null; const index = this.fs.read(`${opts.javaHome}/lib/packages.txt`); if (!index) throw new Error(`no JDK at ${opts.javaHome}; run pnpm jdk`); // modules the file system hides are left out @@ -174,9 +179,15 @@ export class JVM { if (sp <= 0) continue; const mod = line.slice(0, sp); let has = present.get(mod); - if (has === undefined) present.set(mod, (has = this.fs.stat(`${opts.javaHome}/modules/${mod}`) !== null)); + if (has === undefined) present.set(mod, (has = this.image !== null || this.fs.stat(`${opts.javaHome}/modules/${mod}`) !== null)); if (has) this.bootPackages.set(line.slice(sp + 1), mod); } + if (this.image && !opts.localeData && !this.props.has("jdk.module.limitmods")) { + const release = this.fs.read(`${opts.javaHome}/release`); + const modules = release && /(?:^|\n)MODULES="([^"]+)"/.exec(new TextDecoder().decode(release)); + if (!modules) throw new Error("JDK release has no module list"); + this.props.set("jdk.module.limitmods", modules[1].split(" ").filter((m) => m !== "jdk.localedata").join(",")); + } } debug(...msg: any[]) { @@ -212,7 +223,7 @@ export class JVM { const i = name.lastIndexOf("/"); const module = this.bootPackages.get(i < 0 ? "" : name.slice(0, i)); if (!module) return null; - const bytes = this.fs.read(`${this.opts.javaHome}/modules/${module}/${name}.class`); + const bytes = this.image ? this.image.read(module, `${name}.class`) : this.fs.read(`${this.opts.javaHome}/modules/${module}/${name}.class`); if (!bytes) return null; const cf = parseClass(bytes); const superclass = cf.superName ? this.loadBootOrFail(cf.superName) : null; @@ -682,13 +693,10 @@ export class JVM { } // ModuleEntry::should_show_version: the JDK's own modules show no version. - // They are in the image's modules/ directory here, at jrt:/ on HotSpot. shownVersion(mod: JObject): string | null { const version = mod.x?.version as string | null | undefined; if (!version || !this.getField(mod, "java/lang/Module", "name")) return null; - const image = `file://${this.opts.javaHome}/modules/`; - const loc = (mod.x.location as string | null) ?? ""; - const jrt = loc.startsWith(image) ? `jrt:/${loc.slice(image.length)}` : loc; + const jrt = (mod.x.location as string | null) ?? ""; const loader = this.getField(mod, "java/lang/Module", "loader") as JObject | null; const platform = loader?.cls.name === "jdk/internal/loader/ClassLoaders$PlatformClassLoader"; const builtin = !loader || platform || loader.cls.name === "jdk/internal/loader/ClassLoaders$AppClassLoader"; diff --git a/src/natives/misc.ts b/src/natives/misc.ts index 3346579..c03ac20 100644 --- a/src/natives/misc.ts +++ b/src/natives/misc.ts @@ -301,6 +301,8 @@ export function vmProperties(vm: JVM): string[] { ["java.vm.version", "25"], ["java.vm.info", "interpreted mode"], ["jdk.debug", "release"], + ["jdk.image.map.all", "false"], + ["jdk.image.use.jvm.map", "false"], ["sun.java.launcher", "SUN_STANDARD"], ["sun.nio.MaxDirectMemorySize", "-1"], ["sun.nio.PageAlignDirectMemory", "false"], @@ -355,24 +357,6 @@ function* perfBuffer(t: JThread, size: number): NativeGen { return yield t.call(bb.methodMap.get("allocate(I)Ljava/nio/ByteBuffer;")!, size); } -// The boot image as the JDK's jimage reader sees it. The image holds only -// non-class resources and sits at lib/resources.jimage, not lib/modules, -// because a lib/modules file makes the JDK and javac use image mode instead of -// the exploded modules/ tree. The path the reader asks for stays unopened. -function* nativeImageMap(t: JThread, path: string | null): NativeGen { - const vm = t.vm; - if (path !== `${vm.opts.javaHome}/lib/modules`) return null; - const data = vm.fs.read(`${vm.opts.javaHome}/lib/resources.jimage`); - if (!data) return null; - const addr = vm.mem.malloc(data.length); - vm.mem.u8.set(data, addr); - const dbb = vm.loadBoot("java/nio/DirectByteBuffer")!; - if (dbb.state !== INITIALIZED) yield* vm.initGen(t, dbb); - const buf = vm.newObject(dbb); - yield t.call(dbb.methodMap.get("(JJ)V")!, buf, BigInt(addr), BigInt(data.length)); - return buf; -} - // Nanoseconds since the epoch, finer than Date.now(). The performance clock // does not follow changes to the system clock, so it is used only while the // two agree. @@ -406,7 +390,7 @@ export const natives: Natives = { "jdk/internal/loader/RawNativeLibraries.unload0(Ljava/lang/String;J)V": () => undefined, "jdk/internal/perf/Perf.createLong(Ljava/lang/String;IIJ)Ljava/nio/ByteBuffer;": (t) => perfBuffer(t, 8), "jdk/internal/perf/Perf.createByteArray(Ljava/lang/String;II[BI)Ljava/nio/ByteBuffer;": (t, [, , , , , max]) => perfBuffer(t, max), - "jdk/internal/jimage/NativeImageBuffer.getNativeMap(Ljava/lang/String;)Ljava/nio/ByteBuffer;": (t, [path]) => nativeImageMap(t, jstr(t, path)), + "jdk/internal/jimage/NativeImageBuffer.getNativeMap(Ljava/lang/String;)Ljava/nio/ByteBuffer;": () => null, "jdk/internal/perf/Perf.highResCounter()J": () => BigInt(Math.round(performance.now() * 1e6)), "jdk/internal/perf/Perf.highResFrequency()J": () => 1_000_000_000n, "jdk/internal/misc/VM.initialize()V": () => undefined, diff --git a/src/node.ts b/src/node.ts index 7238fda..ff6ac37 100644 --- a/src/node.ts +++ b/src/node.ts @@ -69,6 +69,23 @@ export class NodeBackend implements Backend { return null; } } + canReadRange(p: string) { return !this.writable && p === "/lib/modules"; } + readRange(p: string, off: number, len: number): Uint8Array | null { + try { + const fd = fs.openSync(this.host(p), "r"); + try { + if (!fs.fstatSync(fd).isFile()) return null; + const out = new Uint8Array(len); + let n = 0; + while (n < len) { + const got = fs.readSync(fd, out, n, len - n, off + n); + if (!got) break; + n += got; + } + return out.subarray(0, n); + } finally { fs.closeSync(fd); } + } catch { return null; } + } list(p: string) { if (!this.writable && this.lists.has(p)) return this.lists.get(p)!; let names: string[] | null; @@ -171,7 +188,7 @@ export interface NodeRunOptions { jar?: string; args?: string[]; props?: Record; - // an exploded JDK image instead of build/jdk (or, for dist/node.js, the packaged one) + // a JDK image instead of build/jdk (or, for dist/node.js, the packaged one) jdk?: string; debug?: boolean; stdout?: (b: Uint8Array) => void; @@ -339,6 +356,7 @@ function nodeVm(o: NodeRunOptions): JVM { ...(snapDir && build ? { snapshots: snapshotCache(snapDir, build) } : {}), fs: vfs, javaHome: "/jdk", + localeData: o.localeData, classpath, props: { "java.io.tmpdir": os.tmpdir(), "user.home": os.homedir(), "user.name": os.userInfo().username, ...o.props }, cwd: toVfs(process.cwd()), diff --git a/test/java/JrtUrls.java b/test/java/JrtUrls.java index 6e01b70..ab2585b 100644 --- a/test/java/JrtUrls.java +++ b/test/java/JrtUrls.java @@ -60,6 +60,10 @@ public static void main(String[] args) throws Exception { var fs = FileSystems.getFileSystem(URI.create("jrt:/")); Path catalog = fs.getPath("/modules/java.xml/jdk/xml/internal/jdkcatalog/JDKCatalog.xml"); System.out.println("jrt fs " + Files.size(catalog) + " bytes"); + try (var entries = Files.list(fs.getPath("/modules/java.xml"))) { + System.out.println("java.xml entries " + entries.map(p -> p.getFileName().toString()).sorted().toList()); + } + System.out.println("module " + DocumentBuilderFactory.class.getModule().getName()); // the DTD comes from the JDK's built-in catalog, not the network String xhtml = "\n { + const fs = new VFS(); + fs.mount("/jdk", new NodeBackend("build/jdk")); + const image = new JImage(fs, "/jdk/lib/modules"); + for (const [module, name] of [["java.base", "java/lang/Object.class"], ["java.xml", "javax/xml/XMLConstants.class"]]) { + assert.deepEqual([...image.read(module, name)!.subarray(0, 4)], [0xca, 0xfe, 0xba, 0xbe]); + } + assert.equal(image.read("java.base", "java/lang/NoSuchClass.class"), null); + assert.equal(image.read("java.xml", "java/lang/Object.class"), null); +}); diff --git a/test/programs.test.ts b/test/programs.test.ts index a4866d5..4bc7a5b 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -191,7 +191,10 @@ test("locale data", async () => { return Buffer.concat(stdout).toString("utf8"); }; assert.equal(await run(true), ref.stdout); - assert.match(await run(false), /^de_DE 1,234,567\.89 /m); + const modules = readFileSync(join(root, "build/jdk/release"), "utf8").match(/MODULES="([^"]+)"/)![1] + .split(" ").filter((m) => m !== "jdk.localedata" && m !== "javaweb.tls").join(","); + const limited = spawnSync(hotspot("java"), ["--limit-modules", modules, "-cp", classes, "Locales"], { encoding: "utf8" }); + assert.equal(await run(false), limited.stdout); }); // The host refuses these writes; root is allowed them, so the test needs a normal user. From 7102fabd6cff4e001be4d4b3b1a9a46324dcd64a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:06:28 -0600 Subject: [PATCH 02/15] Preserve file behavior in packaged images. --- src/browser/http.ts | 2 ++ src/image.ts | 2 ++ src/jimage.ts | 4 +++- test/snapshot.test.ts | 10 ++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/browser/http.ts b/src/browser/http.ts index 0ca8baa..c4b3c27 100644 --- a/src/browser/http.ts +++ b/src/browser/http.ts @@ -251,6 +251,8 @@ export class HttpBackend implements Backend { // Fetches missing 64 KB blocks with Range requests, each run of them in // one request. A server that ignores Range sends the whole file instead. + canReadRange(path: string) { return !this.pack.index?.has("/lib/modules") || path === "/lib/modules"; } + readRange(path: string, off: number, len: number): Uint8Array | null { const whole = this.pack.files.get(path) ?? this.cache.get(path); if (whole) return whole.subarray(off, off + len); diff --git a/src/image.ts b/src/image.ts index 7a1c3b7..30b144a 100644 --- a/src/image.ts +++ b/src/image.ts @@ -34,6 +34,8 @@ export class ImageBackend implements Backend { return size === undefined || size < 0 ? null : this.readRange(p, 0, size); } + canReadRange(p: string) { return !this.pack.index?.has("/lib/modules") || p === "/lib/modules"; } + readRange(p: string, off: number, len: number): Uint8Array | null { const size = this.pack.index!.get(p); if (size === undefined || size < 0) return null; diff --git a/src/jimage.ts b/src/jimage.ts index 0edddf8..8464b70 100644 --- a/src/jimage.ts +++ b/src/jimage.ts @@ -64,6 +64,8 @@ export class JImage { const actual = `/${str(attrs[1])}/${attrs[2] ? str(attrs[2]) + "/" : ""}${str(attrs[3])}${attrs[4] ? "." + str(attrs[4]) : ""}`; if (actual !== full) return null; if (attrs[6]) throw new Error("compressed jimage resource"); - return this.fs.readRange(this.path, this.index.length + attrs[5], attrs[7]); + const data = this.fs.readRange(this.path, this.index.length + attrs[5], attrs[7]); + if (!data || data.length !== attrs[7]) throw new Error(`cannot read image resource: ${full}`); + return data; } } diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f75dbf1..60095ba 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -80,6 +80,16 @@ test("other properties boot without the snapshot", async () => { assert.ok(!loaded(snap, ino)); }); +test("locale data uses a separate snapshot", async () => { + const dir = cacheDir(); + await run(dir); + await run(dir); + const [snap] = files(dir, ".snap"); + const ino = age(snap); + await runNode({ classpath: [out], mainClass: "Boot", localeData: true, snapshotDir: dir, stdout: () => {} }); + assert.ok(!loaded(snap, ino)); +}); + test("library mode restores its own snapshot", async () => { const dir = cacheDir(); for (let i = 0; i < 2; i++) await run(dir); From ac7f7bde0e98288b08b0cc89debceeb44b21f91f Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:15:27 -0600 Subject: [PATCH 03/15] Align file mappings to page boundaries. --- docs/development.md | 4 +++- src/memory.ts | 11 ++++++----- src/natives/nio.ts | 2 +- test/memory.test.ts | 12 ++++++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 test/memory.test.ts diff --git a/docs/development.md b/docs/development.md index 8c1cdee..ae93064 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,7 +7,9 @@ The runtime image comes from the Linux x64 JDK, so every machine, a Mac included, builds the same image, with the Linux class library whose natives java-web implements. The host JDK is the HotSpot reference the tests run, linked at `build/hotspot`. The build links the selected modules into `build/jdk/lib/modules`; -`lib/packages.txt` indexes packages for the boot class loader. +`lib/packages.txt` indexes packages for the boot class loader. The distribution +stores this image in `jdk/java.base.jar` and reads it by byte range. Locale data +is enabled with `localeData`; otherwise the VM limits the observable modules. ```sh pnpm install diff --git a/src/memory.ts b/src/memory.ts index 94f69f3..608593a 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -34,16 +34,17 @@ export class NativeMemory { this.dv = new DataView(next); } - malloc(n: number): number { + malloc(n: number, alignment = ALIGN): number { const size = Math.max(ALIGN, Math.ceil(n / ALIGN) * ALIGN); const list = this.free.get(size); - if (list && list.length > 0) { - const addr = list.pop()!; + const reuse = list?.findIndex((addr) => addr % alignment === 0) ?? -1; + if (list && reuse >= 0) { + const [addr] = list.splice(reuse, 1); this.sizes.set(addr, size); return addr; } - const addr = this.top; - this.top += size; + const addr = Math.ceil(this.top / alignment) * alignment; + this.top = addr + size; if (this.top > this.buf.byteLength) this.grow(this.top); this.sizes.set(addr, size); return addr; diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 8276084..929f1a9 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -408,7 +408,7 @@ Object.assign(natives, { // writes only then. [`${UFD}map0(Ljava/io/FileDescriptor;IJJZ)J`]: (t: JThread, [fd, prot, pos, len]: any[]) => { const f = chanFile(t, fd); - const addr = t.vm.mem.malloc(Number(len)); + const addr = t.vm.mem.malloc(Number(len), 4096); const save = f.pos; f.pos = Number(pos); const n = Math.min(f.size - Number(pos), Number(len)); diff --git a/test/memory.test.ts b/test/memory.test.ts new file mode 100644 index 0000000..ea35af7 --- /dev/null +++ b/test/memory.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { NativeMemory } from "../src/memory.ts"; + +test("mapped allocations are page aligned and reusable", () => { + const mem = new NativeMemory(); + mem.malloc(32); + const page = mem.malloc(128, 4096); + assert.equal(page % 4096, 0); + mem.freeBlock(page); + assert.equal(mem.malloc(128, 4096), page); +}); From 6373ca19926d71866eb6d31d592c92b1ef5e57e8 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:24:42 -0600 Subject: [PATCH 04/15] Explain range-backed file selection. --- src/node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node.ts b/src/node.ts index ff6ac37..7589547 100644 --- a/src/node.ts +++ b/src/node.ts @@ -69,6 +69,7 @@ export class NodeBackend implements Backend { return null; } } + // Other open files keep their buffered contents even if the host removes them. canReadRange(p: string) { return !this.writable && p === "/lib/modules"; } readRange(p: string, off: number, len: number): Uint8Array | null { try { From 05b55b5181ca3a6bea0b2dc6c2145ec7794e4c59 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:34:54 -0600 Subject: [PATCH 05/15] Limit locale classes and match the OpenJDK test image. --- scripts/jtreg/run.ts | 3 ++- src/jvm.ts | 4 +++- test/locales/Locales.java | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/jtreg/run.ts b/scripts/jtreg/run.ts index 5aa1aa5..bc30ff0 100644 --- a/scripts/jtreg/run.ts +++ b/scripts/jtreg/run.ts @@ -392,7 +392,8 @@ async function runCase(tc: TestCase): Promise> { if (err) return { status: "skip", why: `javac: ${err}` }; continue; } - const ours = await exec("node", [vm, "--jdk", join(root, "build", "jdk"), ...preview, ...exportsFlags(tc), ...a.opts, ...props(tc), ...cp, ...entry(a), ...a.args], work, timeout); + // Some tests load every class in the image; leave them enough heap under the 1.5 GB scope. + const ours = await exec("node", ["--max-old-space-size=1024", vm, "--jdk", join(root, "build", "jdk"), "--locale-data", ...preview, ...exportsFlags(tc), ...a.opts, ...props(tc), ...cp, ...entry(a), ...a.args], work, timeout); total += ours.ms; if (!ours.ok) return { status: ours.timedOut ? "timeout" : "fail", ms: total, refMs, why: ours.tail }; } diff --git a/src/jvm.ts b/src/jvm.ts index fdb0d23..17ecc7d 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -180,7 +180,9 @@ export class JVM { const mod = line.slice(0, sp); let has = present.get(mod); if (has === undefined) present.set(mod, (has = this.image !== null || this.fs.stat(`${opts.javaHome}/modules/${mod}`) !== null)); - if (has) this.bootPackages.set(line.slice(sp + 1), mod); + if (has && !(this.image && !opts.localeData && !this.props.has("jdk.module.limitmods") && mod === "jdk.localedata")) { + this.bootPackages.set(line.slice(sp + 1), mod); + } } if (this.image && !opts.localeData && !this.props.has("jdk.module.limitmods")) { const release = this.fs.read(`${opts.javaHome}/release`); diff --git a/test/locales/Locales.java b/test/locales/Locales.java index 149c5fb..4f1d1b5 100644 --- a/test/locales/Locales.java +++ b/test/locales/Locales.java @@ -9,6 +9,12 @@ // Formats and names from jdk.localedata, for locales other than English. public class Locales { public static void main(String[] args) { + try { + Class.forName("sun.util.resources.cldr.ext.CurrencyNames_ru_KZ"); + System.out.println("locale class present"); + } catch (ClassNotFoundException e) { + System.out.println("locale class absent"); + } LocalDate day = LocalDate.of(2026, 10, 30); for (Locale l : new Locale[] {Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.JAPAN, Locale.forLanguageTag("pt-BR")}) { System.out.println(l + " " + String.format(l, "%,.2f", 1234567.891) + " | " From c33540ee588fece3af7a2d6f6fd01b7edc5b788b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:49:17 -0600 Subject: [PATCH 06/15] Pack the JDK image ranges a start and javac read, ship the image as one file, and read boot classes with fewer allocations and file opens --- AGENTS.md | 4 +- docs/browser.md | 8 +-- docs/development.md | 8 +-- docs/support.md | 4 +- package.json | 1 - scripts/build-web.ts | 100 +++++++++++++++++++++++------------- scripts/jtreg/run.ts | 4 +- src/browser/http.ts | 116 +++++++++++++++++++++++++++--------------- src/browser/worker.ts | 8 +-- src/image.ts | 15 +++--- src/jimage.ts | 19 +++++-- src/node.ts | 21 ++++++-- test/image.test.ts | 56 +++++++++++--------- 13 files changed, 225 insertions(+), 139 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c2252b..9dfbba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,8 @@ Node, which is how the tests run. IndexedDB and one VM per about:blank iframe. Keep these working. - The release form is an npm package of the built files only (`java-web.js`, `java-web-worker.js`, `node.js` (the Node entry), `cli.js` (the - `java-web` command), `boot.pack`, `jdk/.jar`, - `jdk/javac.pack`, `jdk/jdk.localedata.txt`, and OpenJDK's notices in `jdk/legal/`), versioned, with + `java-web` command), `boot.pack`, `jdk/image.jar`, + `jdk/javac.pack`, and OpenJDK's notices in `jdk/legal/`), versioned, with nothing built at install. A GitHub Actions workflow triggered by a release publishes it to npm; never publish by hand. diff --git a/docs/browser.md b/docs/browser.md index e477ac6..7e7ed7e 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -5,9 +5,11 @@ The npm package holds the built files: `java-web.js`, `java-web-worker.js`, and load `java-web.js` with a classic `