From 9d90e97432fb38d635f4e37c494c80ef910d1db1 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:55:41 -0600 Subject: [PATCH 01/10] Start the VM from a boot snapshot when one was saved for the same inputs --- src/jvm.ts | 101 +++++++-- src/natives/lang.ts | 4 +- src/natives/misc.ts | 14 +- src/natives/ref.ts | 13 ++ src/node.ts | 74 ++++++- src/runtime.ts | 6 +- src/snapshot.ts | 441 ++++++++++++++++++++++++++++++++++++++++ test/snapshot.test.ts | 91 +++++++++ test/snapshot/Boot.java | 23 +++ types/node.d.ts | 3 + 10 files changed, 740 insertions(+), 30 deletions(-) create mode 100644 src/snapshot.ts create mode 100644 test/snapshot.test.ts create mode 100644 test/snapshot/Boot.java diff --git a/src/jvm.ts b/src/jvm.ts index 8fbfd30..cee849f 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -17,11 +17,13 @@ import { Frame, JThread, JavaThrow, NativeFrame, PendingThrow, RUNNABLE, STATUS_BLOCKED, STATUS_RUNNABLE, STATUS_TERMINATED, TERMINATED, WAITING, } from "./thread.ts"; -import type { NativeGen } from "./thread.ts"; +import type { NativeGen, Request } from "./thread.ts"; import { intrinsics, natives as builtinNatives } from "./natives/index.ts"; import { invokePolymorphic, linkCallSite, resolveConstant } from "./indy.ts"; import { hostNative, jniNames } from "./hostnatives.ts"; import { Library } from "./library.ts"; +import { dumpVM, restoreVM, snapshotKey } from "./snapshot.ts"; +import type { SnapshotImage } from "./snapshot.ts"; import type { LibraryRequest } from "./library.ts"; import type { HostNative } from "./hostnatives.ts"; import { PROXY_HOST, PROXY_PORT } from "./http-proxy.ts"; @@ -65,8 +67,20 @@ export interface JvmOptions { env?: Record; // lets the default loopback sockets reach http: and https: URLs through a virtual proxy; also sets the proxy system properties and the TLS pass-through provider fetch?: typeof fetch; + // boot snapshots: build names the VM code and java.home image; load returns + // a saved image for a key or null, and after a boot, save keeps the one taken + // if wants says to take one + snapshots?: { + build: string; + load: (key: string) => SnapshotImage | null; + wants: (key: string) => boolean; + save: (image: SnapshotImage) => void; + }; } +// what a resumed thread body yields first, before the run under it returns +const RESUMED = Object.freeze({ kind: "resume" }) as unknown as Request; + // LauncherHelper launch modes export const LM_CLASS = 1, LM_JAR = 2; @@ -103,6 +117,8 @@ export class JVM { private hiddenCount = 0; // for java.management: classes defined, threads started and the most alive at once classCount = 0; + // every class in creation order, while recording for a snapshot + readonly created: JClass[] | null; threadsStarted = 0; peakThreads = 0; exitCode: number | null = null; @@ -118,6 +134,7 @@ export class JVM { readonly timers = new Set>(); constructor(opts: JvmOptions) { + this.created = opts.snapshots ? [] : null; this.opts = opts; this.fs = opts.fs; const extra = ipv4Literal(opts.hostName); @@ -218,6 +235,7 @@ export class JVM { c.prim = desc; c.state = INITIALIZED; this.prims.set(desc, c); + this.created?.push(c); } return c; } @@ -236,6 +254,7 @@ export class JVM { a.state = INITIALIZED; c.arrayClass = a; this.loaderClasses(c.loader).set(name, a); + this.created?.push(a); return a; } @@ -282,6 +301,7 @@ export class JVM { } c.state = LINKED; if (!hidden) map.set(cf.name, c); + this.created?.push(c); return c; } @@ -333,6 +353,7 @@ export class JVM { const interfaces: JClass[] = []; for (const n of cf.interfaces) interfaces.push(yield* this.loadClassGen(t, loader, n)); const c = this.defineLinked(cf, loader, superclass, interfaces, hidden); + if (this.created) c.bytes = bytes; if (pd) this.setField(this.mirror(c), "java/lang/Class", "protectionDomain", pd); return c; } @@ -963,20 +984,25 @@ export class JVM { const vm = this; const holder = this.getField(obj, "java/lang/Thread", "holder") as JObject; t.daemon = this.getField(holder, "java/lang/Thread$FieldHolder", "daemon") === 1; - t.pushNative((function* () { + t.pushNative(this.threadBody(t, obj, false), null, "V", false); + return t; + } + + // Runs Thread.run, or with resumed, waits for the run already on the stack + // (a thread restored from a snapshot; the generator is stepped to its first + // yield before it is pushed). + *threadBody(t: JThread, obj: JObject, resumed: boolean): NativeGen { + try { + yield resumed ? RESUMED : t.callVirtual(obj, "run", "()V"); + } catch (e) { + if (!(e instanceof JavaThrow)) throw e; try { - yield t.callVirtual(obj, "run", "()V"); - } catch (e) { - if (!(e instanceof JavaThrow)) throw e; - try { - yield t.callVirtual(obj, "dispatchUncaughtException", "(Ljava/lang/Throwable;)V", e.obj); - } catch { - // ignored like HotSpot does, after a report - } + yield t.callVirtual(obj, "dispatchUncaughtException", "(Ljava/lang/Throwable;)V", e.obj); + } catch { + // ignored like HotSpot does, after a report } - yield* vm.exitThread(t); - })(), null, "V", false); - return t; + } + yield* this.exitThread(t); } *exitThread(t: JThread): NativeGen { @@ -1013,7 +1039,9 @@ export class JVM { // as the java launcher sets it if (!this.props.has("sun.java.command")) this.props.set("sun.java.command", [what, ...args].join(" ")); const t = new JThread(this); - t.pushNative(this.mainGen(t, mode, what, args), null, "V", false); + t.pushNative(this.mainGen(t, mode, what, args, this.restore(t)), null, "V", false); + // mainGen halts; ending without it means a VM error killed the thread + t.onExit = () => this.halt(1); this.startThread(t); return this.exited; } @@ -1023,7 +1051,7 @@ export class JVM { startLibrary(): Promise { const t = new JThread(this); const started = new Promise((resolve, reject) => { - t.pushNative(this.libraryGen(t, resolve, reject), null, "V", false); + t.pushNative(this.libraryGen(t, this.restore(t), resolve, reject), null, "V", false); }); this.startThread(t); return started; @@ -1044,9 +1072,9 @@ export class JVM { return this.library.request(req).then((r) => r.value); } - private *libraryGen(t: JThread, started: () => void, failed: (e: Error) => void): NativeGen { + private *libraryGen(t: JThread, restored: boolean, started: () => void, failed: (e: Error) => void): NativeGen { try { - yield* this.bootGen(t); + yield* this.startGen(t, restored); } catch (e) { failed(e instanceof Error ? e : new Error(String(e))); return; @@ -1114,9 +1142,44 @@ export class JVM { } } - private *mainGen(t: JThread, mode: number, what: string, args: string[]): NativeGen { - const boot = (n: string) => this.loadBootOrFail(n); + // Loads the snapshot taken after a boot with the same inputs into the VM, + // with t as its main thread. + private restore(t: JThread): boolean { + const snaps = this.opts.snapshots; + if (!snaps) return false; + const key = snapshotKey(this, snaps.build); + const image = snaps.load(key); + if (image?.key !== key) return false; + restoreVM(this, image, t); + return true; + } + + // Boots, or finishes a restore: the one property the key leaves out is set + // as a boot would have. + private *startGen(t: JThread, restored: boolean): NativeGen { + const snaps = this.opts.snapshots; + if (restored) { + const cmd = this.props.get("sun.java.command"); + const system = this.loadBootOrFail("java/lang/System"); + const set = system.methodMap.get("setProperty(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")!; + if (cmd !== undefined) yield t.call(set, this.str("sun.java.command"), this.str(cmd)); + return; + } yield* this.bootGen(t); + // a snapshot is only a cache: failing to make or keep one never fails the run + if (snaps) { + try { + const key = snapshotKey(this, snaps.build); + if (snaps.wants(key)) snaps.save(dumpVM(this, t, key)); + } catch (e) { + this.debug("snapshot not saved:", e); + } + } + } + + private *mainGen(t: JThread, mode: number, what: string, args: string[], restored: boolean): NativeGen { + const boot = (n: string) => this.loadBootOrFail(n); + yield* this.startGen(t, restored); let code = 0; try { const helper = boot("sun/launcher/LauncherHelper"); diff --git a/src/natives/lang.ts b/src/natives/lang.ts index 64928f5..10229f7 100644 --- a/src/natives/lang.ts +++ b/src/natives/lang.ts @@ -58,9 +58,9 @@ function* waitImpl(t: JThread, self: JObject, ms: bigint): NativeGen { const count = mon.count; vm.releaseMonitor(mon); setThreadStatus(t, ms > 0n ? STATUS_TIMED_WAITING : STATUS_WAITING); - let entry: { t: JThread; wake: () => void } | null = null; + let entry: { t: JThread; wake: () => void; count: number } | null = null; yield t.block(interruptibleWait(t, ms > 0n ? Number(ms) : null, (wake) => { - entry = { t, wake }; + entry = { t, wake, count }; mon.waiters.push(entry); })); const i = mon.waiters.indexOf(entry!); diff --git a/src/natives/misc.ts b/src/natives/misc.ts index f200053..3346579 100644 --- a/src/natives/misc.ts +++ b/src/natives/misc.ts @@ -6,6 +6,7 @@ import { } from "../runtime.ts"; import { JThread, JavaThrow, PendingThrow, STATUS_PARKED, STATUS_PARKED_TIMED, STATUS_RUNNABLE } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; +import type { JVM } from "../jvm.ts"; import { annotationTypes } from "./invoke.ts"; import { interruptibleWait } from "./lang.ts"; import { nestHostOf } from "./reflect.ts"; @@ -262,9 +263,9 @@ Object.assign(unsafe, { const SIGNALS: Record = { HUP: 1, INT: 2, QUIT: 3, KILL: 9, USR1: 10, USR2: 12, PIPE: 13, ALRM: 14, TERM: 15 }; -function platformProperties(t: JThread): (string | null)[] { - const p = t.vm.props; - const cwd = t.vm.opts.cwd ?? "/"; +export function platformProperties(vm: JVM): (string | null)[] { + const p = vm.props; + const cwd = vm.opts.cwd ?? "/"; return [ null, "en", null, null, // display country, language, script, variant "/", // file.separator @@ -286,8 +287,7 @@ function platformProperties(t: JThread): (string | null)[] { ]; } -function vmProperties(t: JThread): string[] { - const vm = t.vm; +export function vmProperties(vm: JVM): string[] { const props = new Map([ ["java.home", vm.opts.javaHome], ["java.class.path", (vm.opts.classpath ?? []).join(":")], @@ -441,9 +441,9 @@ export const natives: Natives = { "jdk/internal/misc/Signal.raise0(I)V": () => undefined, "jdk/internal/util/SystemProps$Raw.platformProperties()[Ljava/lang/String;": (t) => - t.vm.newObjectArray("java/lang/String", platformProperties(t).map((s) => (s === null ? null : t.vm.str(s)))), + t.vm.newObjectArray("java/lang/String", platformProperties(t.vm).map((s) => (s === null ? null : t.vm.str(s)))), "jdk/internal/util/SystemProps$Raw.vmProperties()[Ljava/lang/String;": (t) => - t.vm.newObjectArray("java/lang/String", vmProperties(t).map((s) => t.vm.str(s))), + t.vm.newObjectArray("java/lang/String", vmProperties(t.vm).map((s) => t.vm.str(s))), "jdk/internal/reflect/Reflection.getCallerClass()Ljava/lang/Class;": (t) => callerClass(t), "jdk/internal/reflect/Reflection.getClassAccessFlags(Ljava/lang/Class;)I": (t, [c]) => t.vm.classOf(c).access & 0x7fff, diff --git a/src/natives/ref.ts b/src/natives/ref.ts index a7a84bf..0ec8bc4 100644 --- a/src/natives/ref.ts +++ b/src/natives/ref.ts @@ -55,6 +55,19 @@ function clear(t: JThread, r: JObject) { t.vm.setField(r, R, "referent", null); } +// A weak or phantom reference restored from a snapshot: watch its referent +// again, and put back the pending list. +export function restoreReference(vm: JVM, self: JObject, obj: JObject) { + const s = refs(vm); + self.x = new WeakRef(obj); + const none = vm.getStatic(vm.loadBoot("java/lang/ref/ReferenceQueue")!, "NULL_QUEUE"); + if (vm.getField(self, R, "queue") !== none) s.registry.register(obj, new WeakRef(self), self); +} + +export function restorePending(vm: JVM, pending: JObject[]) { + refs(vm).pending.push(...pending); +} + // Replace methods that read or write the referent field. export const intrinsics: Natives = { [`${R}.(Ljava/lang/Object;Ljava/lang/ref/ReferenceQueue;)V`]: function* (t, [self, obj, queue]): NativeGen { diff --git a/src/node.ts b/src/node.ts index 9f1d281..4414bd6 100644 --- a/src/node.ts +++ b/src/node.ts @@ -4,13 +4,15 @@ import { isAssertionFlag, parseAssertions } from "./assertions.ts"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { setFlagsFromString } from "node:v8"; +import { deserialize, serialize, setFlagsFromString } from "node:v8"; +import { createHash } from "node:crypto"; import { runInNewContext } from "node:vm"; import { fileURLToPath, pathToFileURL } from "node:url"; import { VFS } from "./fs.ts"; import type { Backend, Stat } from "./fs.ts"; import { Bridge, nativeTable } from "./bridge.ts"; import { JVM, LM_CLASS, LM_JAR } from "./jvm.ts"; +import type { SnapshotImage } from "./snapshot.ts"; import { hostNatives } from "./hostnatives.ts"; import { ImageBackend } from "./image.ts"; import { NodeNet } from "./net-node.ts"; @@ -192,6 +194,9 @@ export interface NodeRunOptions { net?: NetBackend; // loopback sockets whose http: requests go to this fetch, instead of host sockets fetch?: typeof fetch; + // where boot snapshots are kept, or null for none; by default + // JAVA_WEB_SNAPSHOT_DIR (empty for none), else java-web/snapshots in the user's cache + snapshotDir?: string | null; } function fixedInput(data: Uint8Array) { @@ -232,6 +237,71 @@ function onOutError(this: NodeJS.WriteStream) { broken.add(this); } +function defaultSnapshotDir(): string | null { + const env = process.env.JAVA_WEB_SNAPSHOT_DIR; + if (env !== undefined) return env || null; + return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "java-web", "snapshots"); +} + +const KEEP_SNAPSHOTS = 16, KEEP_MARKERS = 256; + +// Boot snapshots kept in dir, one file per key. The build covers this code +// (every source file when run from src/) and the java.home image. A key's +// snapshot is taken the second time it boots, so one-off setups leave only +// an empty marker. +function snapshotCache(dir: string, vfs: VFS) { + const h = createHash("sha256"); + const self = fileURLToPath(import.meta.url); + const files = self.endsWith(".ts") + ? fs.readdirSync(path.dirname(self), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort().map((f) => path.join(path.dirname(self), f)) + : [self]; + for (const f of files) h.update(f).update(fs.readFileSync(f)); + for (const f of ["/jdk/release", "/jdk/lib/packages.txt"]) h.update(vfs.read(f) ?? new Uint8Array(0)); + const build = h.digest("hex"); + const name = (key: string) => path.join(dir, createHash("sha256").update(key).digest("hex").slice(0, 32)); + const file = (key: string) => name(key) + ".snap"; + // keeps the most recently used of each kind + const prune = (ext: string, keep: number) => { + const old = fs.readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => path.join(dir, f)) + .map((f) => ({ f, t: fs.statSync(f, { throwIfNoEntry: false })?.mtimeMs ?? 0 })) + .sort((a, b) => b.t - a.t).slice(keep); + for (const { f } of old) fs.rmSync(f, { force: true }); + }; + return { + build, + load: (key: string) => { + try { + const image = deserialize(fs.readFileSync(file(key))); + const now = new Date(); + fs.utimesSync(file(key), now, now); + return image; + } catch { + return null; + } + }, + wants: (key: string) => { + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(name(key) + ".seen", "", { flag: "wx" }); + prune(".seen", KEEP_MARKERS); + return false; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EEXIST"; + } + }, + save: (image: SnapshotImage) => { + const tmp = `${file(image.key)}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmp, serialize(image)); + fs.renameSync(tmp, file(image.key)); + } finally { + fs.rmSync(tmp, { force: true }); + } + prune(".snap", KEEP_SNAPSHOTS); + }, + }; +} + function nodeVm(o: NodeRunOptions): JVM { exposeGc(); const vfs = new VFS(); @@ -251,7 +321,9 @@ function nodeVm(o: NodeRunOptions): JVM { call: (_, req) => vm.library.request(req), release: (_, ids) => vm.library.release(ids), }); + const snapDir = o.snapshotDir === undefined ? defaultSnapshotDir() : o.snapshotDir; const vm: JVM = new JVM({ + ...(snapDir ? { snapshots: snapshotCache(snapDir, vfs) } : {}), fs: vfs, javaHome: "/jdk", classpath, diff --git a/src/runtime.ts b/src/runtime.ts index 2d5c205..2803362 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -193,7 +193,8 @@ export class Monitor { // threads blocked entering the monitor entry: (() => void)[] = []; // threads in wait(), woken by notify - waiters: { t: JThread; wake: () => void }[] = []; + // threads in wait(), woken by notify, with the entry count they gave up + waiters: { t: JThread; wake: () => void; count: number }[] = []; } export class JClass { @@ -226,6 +227,9 @@ export class JClass { // virtual dispatch cache, name+desc -> method vcache = new Map(); // resolved constant pool entries: classes, fields, methods + // the bytes of a class defined by a Java class loader or as hidden, kept + // while recording for a snapshot + bytes?: Uint8Array; rcp: any[] = []; // values of ldc constants, by constant pool index ldc: any[] = []; diff --git a/src/snapshot.ts b/src/snapshot.ts new file mode 100644 index 0000000..1b42d5d --- /dev/null +++ b/src/snapshot.ts @@ -0,0 +1,441 @@ +// Boot snapshots: the VM's state once System.initPhase3 has run, so a later +// start with the same options skips boot, as HotSpot's CDS archive lets it. +// Classes are defined again from the same bytes in the same order; Java +// objects, statics, resolved constants and VM tables come from the image; a +// thread blocked at the snapshot wakes as if spuriously. + +import { parseClass } from "./classfile.ts"; +import type { JVM } from "./jvm.ts"; +import { platformProperties, vmProperties } from "./natives/misc.ts"; +import { restorePending, restoreReference } from "./natives/ref.ts"; +import { MAX_HEAP } from "./runtime.ts"; +import { JArray, JClass, JField, JMethod, JObject, Monitor } from "./runtime.ts"; +import { Frame, JThread, NativeFrame, STATUS_RUNNABLE } from "./thread.ts"; +import type { NativeGen } from "./thread.ts"; + +export interface SnapshotImage { + key: string; + classes: ClassRec[]; + vm: VmRec; + threads: ThreadRec[]; +} + +interface ClassRec { + kind: "prim" | "array" | "boot" | "bytes"; + name: string; + desc?: string; + component?: number; + loader?: unknown; + hidden?: boolean; + bytes?: Uint8Array; + cfName?: string; + superclass?: number | null; + interfaces?: number[]; + state: number; + statics: unknown; + mirror: unknown; + initError: unknown; + rcp: unknown; + ldc: unknown; + vmMembers: unknown; + nestHost: unknown; + links: [string, unknown][]; +} + +interface VmRec { + boot: unknown; + bootPackages: unknown; + bootModules: unknown; + bootUnnamedModule: unknown; + interned: unknown; + hashSeed: number; + classCount: number; + state: [string, unknown][]; + pending: unknown; + mem: { buf: Uint8Array; top: number; sizes: unknown; free: unknown }; +} + +interface ThreadRec { + obj: unknown; + daemon: boolean; + parkPermit: boolean; + held: number; + pins: number; + stackLimit: number; + mounted: unknown; + scopedValueCache: unknown; + frames: unknown[]; +} + +// What boot depends on: the properties the VM gives Java and the options that +// change how boot classes run. A snapshot serves only a VM whose key is the +// same. sun.java.command names the main class, which boot does not use; it is +// set again after a restore. +export function snapshotKey(vm: JVM, build: string): string { + // set after a restore; only whether there is one changes the boot + const vmProps = vmProperties(vm); + const i = vmProps.indexOf("sun.java.command"); + if (i >= 0 && i % 2 === 0) vmProps[i + 1] = ""; + const o = vm.opts; + const modules = [...new Set(vm.bootPackages.values())].sort(); + return JSON.stringify([build, modules, platformProperties(vm), vmProps, o.assertions ?? null, !!o.enablePreview, MAX_HEAP]); +} + +const WAIT0 = "java/lang/Object.wait0(J)V"; +const REF_WAIT = "java/lang/ref/Reference.waitForReferencePendingList()V"; +// vm.state entries rebuilt on demand rather than saved +const TRANSIENT = new Set(["stdin", "refs", "socks"]); + +class Encoder { + private twins = new Map(); + private classIndex = new Map(); + private threadIndex = new Map(); + + constructor(classes: JClass[], threads: JThread[]) { + classes.forEach((c, i) => this.classIndex.set(c, i)); + threads.forEach((t, i) => this.threadIndex.set(t, i)); + } + + cls(c: JClass, path: string): number { + const i = this.classIndex.get(c); + if (i === undefined) throw new Error(`snapshot: class ${c.name} was not recorded (${path})`); + return i; + } + + private work: (() => void)[] = []; + + // the twin of v; containers are filled in by drain, so deep object graphs + // need no deep recursion + enc(v: any, path: string): any { + const t = this.twin(v, path); + while (this.work.length) this.work.pop()!(); + return t; + } + + private twin(v: any, path: string): any { + if (v === null || (typeof v !== "object" && typeof v !== "function")) return v; + const memo = this.twins.get(v); + if (memo !== undefined) return memo; + if (typeof v === "function") throw new Error(`snapshot: cannot save function ${v.name || "(anonymous)"} in ${path}`); + if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) return v; + let t: any; + if (v instanceof JObject) { + t = { $: v instanceof JArray ? "A" : "O", c: this.cls(v.cls, path), h: v.hash }; + this.work.push(() => { + if (v instanceof JArray) t.a = Array.isArray(v.a) ? v.a.map((e) => this.twin(e, path)) : v.a; + else t.f = v.f.map((e) => this.twin(e, path)); + if (v.mon) t.n = this.twin(v.mon, path); + if (v.x !== undefined) t.x = v.x instanceof WeakRef ? { $: "W", v: this.twin(v.x.deref() ?? null, path) } : this.twin(v.x, path); + }); + } else if (v instanceof JClass) t = { $: "C", i: this.cls(v, path) }; + else if (v instanceof JMethod) t = { $: "M", c: this.cls(v.cls, path), k: v.key }; + else if (v instanceof JField) t = { $: "F", c: this.cls(v.cls, path), i: v.cls.fields.indexOf(v) }; + else if (v instanceof JThread) { + const i = this.threadIndex.get(v); + if (i === undefined) throw new Error(`snapshot: unknown thread in ${path}`); + t = { $: "T", i }; + } else if (v instanceof Monitor) { + if (v.entry.length) throw new Error(`snapshot: a thread is blocked entering a monitor in ${path}`); + t = { $: "N", n: v.count }; + this.work.push(() => (t.o = this.twin(v.owner, path))); + } else if (v instanceof Map) { + t = new Map(); + this.work.push(() => { for (const [k, x] of v) t.set(this.twin(k, path), this.twin(x, path)); }); + } else if (v instanceof Set) { + t = new Set(); + this.work.push(() => { for (const x of v) t.add(this.twin(x, path)); }); + } else if (Array.isArray(v)) { + t = new Array(v.length); + this.work.push(() => v.forEach((x, i) => (t[i] = this.twin(x, path)))); + } else { + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) throw new Error(`snapshot: cannot save ${v.constructor?.name} in ${path}`); + const o: any = {}; + t = { $: "P", v: o }; + this.work.push(() => { for (const k of Object.keys(v)) o[k] = this.twin(v[k], path); }); + } + this.twins.set(v, t); + return t; + } +} + +class Decoder { + private twins = new Map(); + private fills: (() => void)[] = []; + readonly references: [JObject, unknown][] = []; + + readonly classes: JClass[]; + private threads: JThread[]; + + constructor(classes: JClass[], threads: JThread[]) { + this.classes = classes; + this.threads = threads; + } + + dec(v: any): any { + if (v === null || typeof v !== "object") return v; + const memo = this.twins.get(v); + if (memo !== undefined) return memo; + if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) return v; + if (v instanceof Map) { + const m = new Map(); + this.twins.set(v, m); + for (const [k, x] of v) m.set(this.dec(k), this.dec(x)); + return m; + } + if (v instanceof Set) { + const s = new Set(); + this.twins.set(v, s); + for (const x of v) s.add(this.dec(x)); + return s; + } + if (Array.isArray(v)) { + const a = new Array(v.length); + this.twins.set(v, a); + v.forEach((x, i) => (a[i] = this.dec(x))); + return a; + } + switch (v.$) { + case "O": case "A": { + const cls = this.classes[v.c]; + if (!cls) throw new Error(`snapshot: object of class #${v.c} before its class`); + const o = v.$ === "A" ? new JArray(cls, Array.isArray(v.a) ? new Array(v.a.length) : v.a) : new JObject(cls, []); + o.hash = v.h; + this.twins.set(v, o); + this.fills.push(() => { + if (o instanceof JArray) { + if (Array.isArray(v.a)) v.a.forEach((x: unknown, i: number) => ((o.a as unknown[])[i] = this.dec(x))); + } else o.f = v.f.map((x: unknown) => this.dec(x)); + if (v.n) o.mon = this.dec(v.n); + if (v.x !== undefined) { + if (v.x?.$ === "W") this.references.push([o, v.x.v]); + else o.x = this.dec(v.x); + } + }); + return o; + } + case "C": return this.classes[v.i]; + case "M": return this.classes[v.c].methodMap.get(v.k); + case "F": return this.classes[v.c].fields[v.i]; + case "T": return this.threads[v.i]; + case "N": { + const m = new Monitor(); + this.twins.set(v, m); + m.count = v.n; + m.owner = this.dec(v.o); + return m; + } + case "P": { + const o: any = {}; + this.twins.set(v, o); + for (const k of Object.keys(v.v)) o[k] = this.dec(v.v[k]); + return o; + } + } + throw new Error(`snapshot: unknown record ${JSON.stringify(v).slice(0, 80)}`); + } + + // fills the objects decoded so far, and those their fields lead to + flush() { + while (this.fills.length) this.fills.pop()!(); + } +} + +// The image of a VM whose boot just finished on main, which keeps running +// and is not saved itself. +export function dumpVM(vm: JVM, main: JThread, key: string): SnapshotImage { + const classes = vm.created!; + const others = [...vm.threads].filter((t) => t !== main); + const e = new Encoder(classes, [main, ...others]); + const classRecs = classes.map((c, i): ClassRec => { + const p = `class ${c.name}`; + const rec: ClassRec = { + kind: c.prim ? "prim" : c.component ? "array" : c.bytes ? "bytes" : "boot", + name: c.name, + state: c.state, + statics: e.enc(c.statics, `${p}.statics`), + mirror: e.enc(c.mirror, `${p}.mirror`), + initError: e.enc(c.initError, `${p}.initError`), + rcp: e.enc(c.rcp, `${p}.rcp`), + ldc: e.enc(c.ldc, `${p}.ldc`), + vmMembers: e.enc(c.vmMembers, `${p}.vmMembers`), + nestHost: e.enc(c.nestHost, `${p}.nestHost`), + links: c.methods.filter((m) => m.link).map((m) => [m.key, e.enc(m.link, `${p}.${m.key}.link`)]), + }; + if (c.initThread || c.initWaiters.length) throw new Error(`snapshot: ${c.name} is being initialized`); + if (rec.kind === "prim") rec.desc = c.prim!; + else if (rec.kind === "array") rec.component = e.cls(c.component!, p); + else if (rec.kind === "bytes") { + rec.loader = e.enc(c.loader, `${p}.loader`); + rec.hidden = c.hidden; + rec.bytes = c.bytes; + rec.cfName = c.cf!.name; + rec.superclass = c.superclass ? e.cls(c.superclass, p) : null; + rec.interfaces = c.interfaces.map((x) => e.cls(x, p)); + } else if (c.loader !== null || c.hidden) throw new Error(`snapshot: no bytes for ${c.name}`); + if (i !== classes.indexOf(c)) throw new Error("snapshot: class recorded twice"); + return rec; + }); + const s = vm.state; + const refs = s.get("refs") as { pending: JObject[] } | undefined; + const mem = vm.mem as any; + const vmRec: VmRec = { + boot: e.enc(vm.boot, "boot"), + bootPackages: e.enc(vm.bootPackages, "bootPackages"), + bootModules: e.enc(vm.bootModules, "bootModules"), + bootUnnamedModule: e.enc(vm.bootUnnamedModule, "bootUnnamedModule"), + interned: e.enc((vm as any).interned, "interned"), + hashSeed: (vm as any).hashSeed, + classCount: vm.classCount, + state: [...s].filter(([k, x]) => { + if (k === "socks" && x.socks.size) throw new Error("snapshot: a socket is open"); + return !TRANSIENT.has(k) && !k.startsWith("parked:"); + }).map(([k, v]) => [k, e.enc(v, `state.${k}`)]), + pending: e.enc(refs?.pending ?? [], "refs.pending"), + mem: { buf: mem.u8.slice(0, mem.top), top: mem.top, sizes: e.enc(mem.sizes, "mem"), free: e.enc(mem.free, "mem") }, + }; + const threads = others.map((t): ThreadRec => { + const p = `thread ${t.name}`; + const frames = t.frames.map((f, i) => { + if (f instanceof Frame) { + if (f.direct) throw new Error(`snapshot: direct frame in ${p}`); + return { $: "Fr", m: e.enc(f.m, p), pc: f.pc, sp: f.sp, l: e.enc(f.locals, p), s: e.enc(f.stack, p), k: e.enc(f.lock, p), nl: f.needLock }; + } + if (f.input !== undefined || f.thrown) throw new Error(`snapshot: native frame with a pending value in ${p}`); + if (i === 0 && f.m === null) return { $: "body" }; + const key = f.m ? `${f.m.cls.name}.${f.m.key}` : "vm"; + if (key === REF_WAIT) return { $: "native", m: e.enc(f.m, p) }; + if (key === WAIT0) { + const o = (t.frames[i - 1] as Frame).locals[0] as JObject; + const w = o.mon?.waiters.find((x) => x.t === t); + if (!w) throw new Error(`snapshot: ${p} in wait0 is not a waiter`); + return { $: "rewait", m: e.enc(f.m, p), o: e.enc(o, p), n: w.count }; + } + throw new Error(`snapshot: ${p} is blocked in ${key}`); + }); + return { + obj: e.enc(t.obj, p), + daemon: t.daemon, + parkPermit: t.parkPermit, + held: t.held, + pins: t.pins, + stackLimit: t.stackLimit, + mounted: e.enc(t.mounted, p), + scopedValueCache: e.enc(t.scopedValueCache, p), + frames, + }; + }); + return { key, classes: classRecs, vm: vmRec, threads: [{ obj: e.enc(main.obj, "main") } as ThreadRec, ...threads] }; +} + +// A waiter woken as if spuriously: takes its monitor back with the count it gave up. +function* rewait(t: JThread, o: JObject, count: number): NativeGen { + const mon = o.mon!; + while (mon.owner !== null) yield t.block(new Promise((r) => mon.entry.push(r))); + mon.owner = t; + mon.count = count; + t.vm.setThreadStatus(t, STATUS_RUNNABLE); +} + +// Puts a VM built with the snapshot's options into its state after boot. +// main becomes the snapshot's main thread; the others are started. +export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { + const others = image.threads.slice(1).map(() => new JThread(vm)); + const threads = [main, ...others]; + const classes: JClass[] = []; + const d = new Decoder(classes, threads); + for (const r of image.classes) { + let c: JClass | null; + if (r.kind === "prim") c = vm.primClass(r.desc!); + else if (r.kind === "array") c = vm.arrayOf(classes[r.component!]); + else if (r.kind === "boot") c = vm.loadBoot(r.name); + else { + let cf = parseClass(r.bytes!, vm.opts.enablePreview); + if (cf.name !== r.cfName) cf = { ...cf, name: r.cfName!, renamedFrom: cf.name }; + const sup = r.superclass === null ? null : classes[r.superclass!]; + c = vm.defineLinked(cf, d.dec(r.loader), sup, r.interfaces!.map((i) => classes[i]), r.hidden); + } + if (!c || c.name !== r.name) throw new Error(`snapshot: ${r.name} came back as ${c?.name}`); + classes.push(c); + } + d.flush(); + image.classes.forEach((r, i) => { + const c = classes[i]; + c.state = r.state; + c.statics = d.dec(r.statics); + c.mirror = d.dec(r.mirror); + c.initError = d.dec(r.initError); + c.rcp = d.dec(r.rcp); + c.ldc = d.dec(r.ldc); + c.vmMembers = d.dec(r.vmMembers); + c.nestHost = d.dec(r.nestHost); + for (const [k, l] of r.links) c.methodMap.get(k)!.link = d.dec(l); + }); + d.flush(); + const v = image.vm; + const boot = d.dec(v.boot) as Map; + vm.boot.clear(); + for (const [k, c] of boot) vm.boot.set(k, c); + for (const [k, p] of d.dec(v.bootPackages) as Map) vm.bootPackages.set(k, p); + for (const [k, m] of d.dec(v.bootModules) as Map) vm.bootModules.set(k, m); + vm.bootUnnamedModule = d.dec(v.bootUnnamedModule); + const interned = (vm as any).interned as Map; + for (const [k, s] of d.dec(v.interned) as Map) interned.set(k, s); + (vm as any).hashSeed = v.hashSeed; + vm.classCount = v.classCount; + for (const [k, x] of v.state) vm.state.set(k, d.dec(x)); + const mem = vm.mem as any; + mem.buf = new ArrayBuffer(Math.max(mem.buf.byteLength, v.mem.buf.length)); + mem.u8 = new Uint8Array(mem.buf); + mem.dv = new DataView(mem.buf); + mem.u8.set(v.mem.buf); + mem.top = v.mem.top; + mem.sizes = d.dec(v.mem.sizes); + mem.free = d.dec(v.mem.free); + d.flush(); + + main.obj = d.dec(image.threads[0].obj); + main.obj!.x = main; + image.threads.slice(1).forEach((r, i) => { + const t = others[i]; + t.obj = d.dec(r.obj); + t.daemon = r.daemon; + t.parkPermit = r.parkPermit; + t.held = r.held; + t.pins = r.pins; + t.stackLimit = r.stackLimit; + t.mounted = d.dec(r.mounted); + t.scopedValueCache = d.dec(r.scopedValueCache); + for (const f of r.frames as any[]) { + if (f.$ === "Fr") { + const fr = new Frame(d.dec(f.m)); + fr.locals = d.dec(f.l); + fr.stack = d.dec(f.s); + fr.pc = f.pc; + fr.sp = f.sp; + fr.lock = d.dec(f.k); + fr.needLock = f.nl; + t.frames.push(fr); + } else if (f.$ === "body") { + const gen = vm.threadBody(t, t.obj!, true); + gen.next(); + t.frames.push(new NativeFrame(gen, null, "V", false)); + } else if (f.$ === "native") { + const m = d.dec(f.m) as JMethod; + t.frames.push(new NativeFrame(vm.bindNative(m)(t, []) as NativeGen, m, m.ret, false)); + } else { + const m = d.dec(f.m) as JMethod; + t.frames.push(new NativeFrame(rewait(t, d.dec(f.o), f.n), m, "V", false)); + } + } + }); + d.flush(); + restorePending(vm, d.dec(v.pending)); + for (const [o, referent] of d.references) { + const r = d.dec(referent); + if (r) restoreReference(vm, o, r); + else o.x = null; + } + d.flush(); + for (const t of others) vm.startThread(t); +} diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts new file mode 100644 index 0000000..44c1bb9 --- /dev/null +++ b/test/snapshot.test.ts @@ -0,0 +1,91 @@ +// Boot snapshots: a VM restored from one behaves as one that booted. + +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { runNode, startLibrary } from "../src/node.ts"; +import { hotspot } from "../scripts/hotspot.ts"; + +const root = join(import.meta.dirname, ".."); +const out = join(root, "build", "test-snapshot"); +mkdirSync(out, { recursive: true }); +execFileSync(hotspot("javac"), ["-d", out, join(root, "test", "snapshot", "Boot.java")]); +const dirs: string[] = []; +after(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true }))); + +function cacheDir() { + const d = mkdtempSync(join(tmpdir(), "java-web-snap-")); + dirs.push(d); + return d; +} + +async function run(snapshotDir: string | null, props: Record = {}) { + const chunks: Uint8Array[] = []; + const code = await runNode({ classpath: [out], mainClass: "Boot", args: ["a", "b"], props, snapshotDir, stdout: (b) => chunks.push(b) }); + return { code, stdout: Buffer.concat(chunks).toString("utf8") }; +} + +const files = (dir: string, ext: string) => readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => join(dir, f)); + +// marks a snapshot unused, so a load shows in its time +function age(file: string) { + utimesSync(file, new Date(0), new Date(0)); +} +const used = (file: string) => statSync(file).mtimeMs > 0; + +test("a snapshot is taken on the second boot and restored on the third", async () => { + const ref = spawnSync(hotspot("java"), ["-cp", out, "Boot", "a", "b"], { encoding: "utf8" }); + const expected = { code: ref.status, stdout: ref.stdout }; + const dir = cacheDir(); + assert.deepEqual(await run(dir), expected); + assert.equal(files(dir, ".snap").length, 0); + assert.deepEqual(await run(dir), expected); + const [snap] = files(dir, ".snap"); + age(snap); + assert.deepEqual(await run(dir), expected); + assert.ok(used(snap)); +}); + +test("other properties boot without the snapshot", async () => { + const dir = cacheDir(); + await run(dir); + await run(dir); + const [snap] = files(dir, ".snap"); + age(snap); + const got = await run(dir, { "snap.prop": "x" }); + assert.equal(got.stdout.split("\n")[2], "x"); + assert.ok(!used(snap)); +}); + +test("library mode restores its own snapshot", async () => { + const dir = cacheDir(); + for (let i = 0; i < 2; i++) await run(dir); + const where = async () => { + const vm = await startLibrary({ classpath: [out], snapshotDir: dir }); + try { + return await vm.callStatic("Boot", "where", []); + } finally { + vm.halt(0); + } + }; + assert.equal(await where(), "null on main"); + assert.equal(await where(), "null on main"); + assert.equal(files(dir, ".snap").length, 2); + for (const f of files(dir, ".snap")) age(f); + assert.equal(await where(), "null on main"); + assert.equal(files(dir, ".snap").filter(used).length, 1); +}); + +test("a snapshot that cannot be saved leaves the run as it was", async () => { + const dir = cacheDir(); + const expected = await run(null); + assert.deepEqual(await run(dir), expected); + const [marker] = files(dir, ".seen"); + // the snapshot's own path taken by a directory, so saving it fails + mkdirSync(marker.replace(/\.seen$/, ".snap")); + assert.deepEqual(await run(dir), expected); + assert.deepEqual(files(dir, ".tmp"), []); +}); diff --git a/test/snapshot/Boot.java b/test/snapshot/Boot.java new file mode 100644 index 0000000..240b5e5 --- /dev/null +++ b/test/snapshot/Boot.java @@ -0,0 +1,23 @@ +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; + +// What a restored VM must still have: its command, properties, and the +// Reference Handler that was waiting when the snapshot was taken. +public class Boot { + public static void main(String[] args) throws Exception { + System.out.println(String.join(",", args)); + System.out.println(where()); + System.out.println(System.getProperty("snap.prop")); + ReferenceQueue q = new ReferenceQueue<>(); + WeakReference w = new WeakReference<>(new Object(), q); + while (q.remove(10) == null) System.gc(); + System.out.println("enqueued " + (w.get() == null)); + Thread t = new Thread(() -> System.out.println("thread " + Thread.currentThread().getName()), "worker"); + t.start(); + t.join(); + } + + static String where() { + return System.getProperty("sun.java.command") + " on " + Thread.currentThread().getName(); + } +} diff --git a/types/node.d.ts b/types/node.d.ts index 0ab884d..9e873af 100644 --- a/types/node.d.ts +++ b/types/node.d.ts @@ -30,6 +30,9 @@ export interface NodeRunOptions { localeData?: boolean; // loopback sockets whose http: requests go to this fetch, instead of host sockets fetch?: typeof fetch; + // where boot snapshots are kept, or null for none; by default + // JAVA_WEB_SNAPSHOT_DIR (empty for none), else java-web/snapshots in the user's cache + snapshotDir?: string | null; } // A running VM, as startLibrary returns it. From 9314d6964966237f91b7fb0eb5c7ae9c7fbca234 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:58:15 -0600 Subject: [PATCH 02/10] Keep boot snapshots in IndexedDB for browser VMs --- scripts/build-web.ts | 10 ++++++ src/browser/idb.ts | 75 ++++++++++++++++++++++++++++++++++--------- src/browser/worker.ts | 26 ++++++++++++++- src/jvm.ts | 37 ++++++++++++--------- 4 files changed, 117 insertions(+), 31 deletions(-) diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 399990e..2c53bf1 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -4,6 +4,7 @@ // Run `pnpm jdk` first. import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { build } from "esbuild"; @@ -21,8 +22,17 @@ if (!existsSync(join(jdk, "lib", "packages.txt"))) throw new Error("run `pnpm jd rmSync(dist, { recursive: true, force: true }); mkdirSync(join(dist, "samples"), { recursive: true }); +// names the VM code and JDK image, for the worker's boot snapshots +const buildId = createHash("sha256"); +for (const f of readdirSync(join(root, "src"), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort()) { + buildId.update(f).update(readFileSync(join(root, "src", f))); +} +for (const f of ["release", "lib/packages.txt"]) buildId.update(readFileSync(join(jdk, f))); +const define = { JAVA_WEB_BUILD: JSON.stringify(buildId.digest("hex")) }; + for (const [entry, out] of [["api.ts", "java-web.js"], ["worker.ts", "java-web-worker.js"]]) { await build({ + define, entryPoints: [join(root, "src", "browser", entry)], outfile: join(dist, out), bundle: true, diff --git a/src/browser/idb.ts b/src/browser/idb.ts index 73fcacc..e94c9fe 100644 --- a/src/browser/idb.ts +++ b/src/browser/idb.ts @@ -1,9 +1,14 @@ -// Keeps a MemoryBackend in IndexedDB, so /files/ survives page reloads. -// The whole store loads at startup; changes are written back in batches. +// Keeps a MemoryBackend in IndexedDB, so /files/ survives page reloads, and +// the boot snapshots of recent starts. The whole file store loads at startup; +// changes are written back in batches. import type { MemoryBackend, MemoryEntry } from "../fs.ts"; +import type { SnapshotImage } from "../snapshot.ts"; -const DB = "java-web", STORE = "files"; +const DB = "java-web", FILES = "files", SNAPSHOTS = "snapshots"; +// snapshot keys, most recently used first +const ORDER = "#order"; +const KEEP_SNAPSHOTS = 4; function request(r: IDBRequest): Promise { return new Promise((resolve, reject) => { @@ -12,19 +17,29 @@ function request(r: IDBRequest): Promise { }); } +// null without IndexedDB (private mode, some workers) +let opened: Promise | null = null; +function db(): Promise { + return (opened ??= (async () => { + if (typeof indexedDB === "undefined") return null; + try { + const open = indexedDB.open(DB, 2); + open.onupgradeneeded = () => { + for (const s of [FILES, SNAPSHOTS]) if (!open.result.objectStoreNames.contains(s)) open.result.createObjectStore(s); + }; + return await request(open); + } catch { + return null; + } + })()); +} + // Loads saved files into backend and keeps saving its changes. Without -// IndexedDB (private mode, some workers) the backend stays memory-only. +// IndexedDB the backend stays memory-only. export async function persist(backend: MemoryBackend): Promise { - if (typeof indexedDB === "undefined") return; - let db: IDBDatabase; - try { - const open = indexedDB.open(DB, 1); - open.onupgradeneeded = () => open.result.createObjectStore(STORE); - db = await request(open); - } catch { - return; - } - const tx = db.transaction(STORE, "readonly").objectStore(STORE); + const d = await db(); + if (!d) return; + const tx = d.transaction(FILES, "readonly").objectStore(FILES); const [keys, values] = await Promise.all([request(tx.getAllKeys()), request(tx.getAll())]); keys.forEach((k, i) => { const v = values[i] as MemoryEntry | "dir"; @@ -36,7 +51,7 @@ export async function persist(backend: MemoryBackend): Promise { let scheduled = false; const flush = () => { scheduled = false; - const store = db.transaction(STORE, "readwrite").objectStore(STORE); + const store = d.transaction(FILES, "readwrite").objectStore(FILES); for (const [path, entry] of pending) { if (entry === null) store.delete(path); else store.put(entry, path); @@ -51,3 +66,33 @@ export async function persist(backend: MemoryBackend): Promise { } }; } + +// Moves key to the front of the use order, dropping snapshots past the limit; +// image, when given, is stored under key. +async function useSnapshot(key: string, image?: SnapshotImage) { + const d = await db(); + if (!d) return; + const store = d.transaction(SNAPSHOTS, "readwrite").objectStore(SNAPSHOTS); + if (image) store.put(image, key); + const order = [key, ...((await request(store.get(ORDER))) as string[] | undefined ?? []).filter((k) => k !== key)]; + for (const k of order.slice(KEEP_SNAPSHOTS)) store.delete(k); + store.put(order.slice(0, KEEP_SNAPSHOTS), ORDER); +} + +export async function loadSnapshot(key: string): Promise { + try { + const d = await db(); + if (!d) return null; + const image = (await request(d.transaction(SNAPSHOTS, "readonly").objectStore(SNAPSHOTS).get(key))) as SnapshotImage | undefined; + if (!image) return null; + useSnapshot(key).catch(() => {}); + return image; + } catch { + return null; + } +} + +// image must not change afterwards: it is stored once the database is open +export function saveSnapshot(image: SnapshotImage) { + useSnapshot(image.key, image).catch(() => {}); +} diff --git a/src/browser/worker.ts b/src/browser/worker.ts index 1465fd2..96ce2b1 100644 --- a/src/browser/worker.ts +++ b/src/browser/worker.ts @@ -5,7 +5,8 @@ import { MemoryBackend, VFS } from "../fs.ts"; import { JVM } from "../jvm.ts"; import { HttpBackend, addIndex, parsePack } from "./http.ts"; -import { persist } from "./idb.ts"; +import { loadSnapshot, persist, saveSnapshot } from "./idb.ts"; +import type { SnapshotImage } from "../snapshot.ts"; import type { LibraryError, LibraryReply } from "../library.ts"; import type { NativeReply } from "../bridge.ts"; import type { FromWorker, InitMessage, ToWorker } from "./protocol.ts"; @@ -111,8 +112,31 @@ function fileClosed(path: string) { if (data) post({ type: "download", name: path.slice(path.lastIndexOf("/") + 1), data: data.slice() }); } +// set by the build: the VM code and JDK image a snapshot is valid for +declare const JAVA_WEB_BUILD: string; + +// Boot snapshots of this page's VMs, also kept in IndexedDB for later loads. +// A VM changes the image it restores, so each gets a copy. +const snapshots = new Map(); +const snapshotCache = { + build: JAVA_WEB_BUILD, + load: async (key: string) => { + const image = snapshots.get(key) ?? await loadSnapshot(key); + if (!image) return null; + snapshots.set(key, image); + return structuredClone(image); + }, + wants: () => true, + save: (image: SnapshotImage) => { + const copy = structuredClone(image); + snapshots.set(image.key, copy); + saveSnapshot(copy); + }, +}; + function newVm(classpath: string[], vmId: number): JVM { return new JVM({ + snapshots: snapshotCache, fs: vfs!, javaHome: "/jdk", classpath, diff --git a/src/jvm.ts b/src/jvm.ts index cee849f..8e8b29c 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -72,7 +72,7 @@ export interface JvmOptions { // if wants says to take one snapshots?: { build: string; - load: (key: string) => SnapshotImage | null; + load: (key: string) => SnapshotImage | null | Promise; wants: (key: string) => boolean; save: (image: SnapshotImage) => void; }; @@ -1039,10 +1039,12 @@ export class JVM { // as the java launcher sets it if (!this.props.has("sun.java.command")) this.props.set("sun.java.command", [what, ...args].join(" ")); const t = new JThread(this); - t.pushNative(this.mainGen(t, mode, what, args, this.restore(t)), null, "V", false); - // mainGen halts; ending without it means a VM error killed the thread - t.onExit = () => this.halt(1); - this.startThread(t); + this.restore(t, (restored) => { + t.pushNative(this.mainGen(t, mode, what, args, restored), null, "V", false); + // mainGen halts; ending without it means a VM error killed the thread + t.onExit = () => this.halt(1); + this.startThread(t); + }); return this.exited; } @@ -1050,11 +1052,12 @@ export class JVM { // library, one call at a time on the main thread, as in CheerpJ's library mode. startLibrary(): Promise { const t = new JThread(this); - const started = new Promise((resolve, reject) => { - t.pushNative(this.libraryGen(t, this.restore(t), resolve, reject), null, "V", false); + return new Promise((resolve, reject) => { + this.restore(t, (restored) => { + t.pushNative(this.libraryGen(t, restored, resolve, reject), null, "V", false); + this.startThread(t); + }); }); - this.startThread(t); - return started; } private lib: Library | null = null; @@ -1143,15 +1146,19 @@ export class JVM { } // Loads the snapshot taken after a boot with the same inputs into the VM, - // with t as its main thread. - private restore(t: JThread): boolean { + // with t as its main thread, then starts it; synchronously when load is. + private restore(t: JThread, start: (restored: boolean) => void) { const snaps = this.opts.snapshots; - if (!snaps) return false; + if (!snaps) return start(false); const key = snapshotKey(this, snaps.build); + const done = (image: SnapshotImage | null) => { + const restored = image?.key === key && this.exitCode === null; + if (restored) restoreVM(this, image, t); + start(restored); + }; const image = snaps.load(key); - if (image?.key !== key) return false; - restoreVM(this, image, t); - return true; + if (image instanceof Promise) image.then(done, () => done(null)); + else done(image); } // Boots, or finishes a restore: the one property the key leaves out is set From 3ec165218dc1cb43f4e1b578ac07cb4cdfe51873 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:02:26 -0600 Subject: [PATCH 03/10] Document boot snapshots --- AGENTS.md | 5 +++++ docs/browser.md | 5 +++++ docs/node.md | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ffdddfa..83a4a64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Node, which is how the tests run. - `src/interp.ts` bytecode interpreter and per-thread run loop - `src/jvm.ts` loading, linking, initialization, resolution, scheduler, boot - `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, value conversion; `src/bridge.ts` the JS proxies over it - `src/natives/` JS implementations of JDK native methods, by package; @@ -95,5 +96,9 @@ Node, which is how the tests run. - **handle**: how the VM names a Java object it gave to JS; the page's proxy holds it until the proxy is dropped. - **wire value**: a value as it crosses between the VM and JS (library.ts). +- **boot snapshot**: the VM right after the JDK's boot, saved and loaded on + later starts with the same key (build, class path, properties, options, + working directory) instead of booting. Kept in `~/.cache/java-web` under + Node and IndexedDB in browsers. - **Sock**: a socket, event or epoll file descriptor behind the `sun.nio.ch` natives. A backend (host sockets under Node, loopback inside the VM) moves its bytes. diff --git a/docs/browser.md b/docs/browser.md index 4eede28..524de37 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -119,3 +119,8 @@ Natives run on the page's main thread while Java runs in a Worker, so a typed 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. + +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 +later visits. diff --git a/docs/node.md b/docs/node.md index d1b7bb7..eae523f 100644 --- a/docs/node.md +++ b/docs/node.md @@ -51,6 +51,19 @@ resolves to the exit code. Its options: - `hostName` (what `InetAddress.getLocalHost()` reports), `maxRssMb`, `jdk`, `debug` - `fetch`: sockets stay inside the VM, and `http:` requests go to this `fetch`, as in the browser +- `snapshotDir`: where boot snapshots are kept, or `null` for none (see below) + `startLibrary(options)` starts a VM in library mode, and `libraryOf(vm)` is its `lib`, used as `cheerpjRunLibrary`'s result is in the browser. `vm.halt(0)` stops it. + +## Boot snapshots + +Most of a start is the JDK's own boot. The second time a setup boots, java-web +saves the VM as it is after boot, and later starts with the same setup load it +instead, which is about three times faster. A setup is the java-web build, the +working directory, the class path, system properties and VM options: boot +reads all of them. The snapshots, about 1 MB each, are kept in +`$XDG_CACHE_HOME/java-web/snapshots` (`~/.cache/java-web/snapshots`), the 16 +most recently used. Set `JAVA_WEB_SNAPSHOT_DIR` to keep them elsewhere, or to +an empty value to turn them off. From 16560a14187e8910417927c3f0e34031a540c5ec Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:08:41 -0600 Subject: [PATCH 04/10] Fall back to booting when a snapshot cannot be restored, and key snapshots on the image digest --- scripts/build-jdk.sh | 12 +++++++ scripts/build-web.ts | 5 +-- src/browser/idb.ts | 80 +++++++++++++++++++++++++------------------ src/browser/worker.ts | 47 ++++++++++++++++++------- src/jvm.ts | 41 ++++++++++++++++------ src/node.ts | 65 +++++++++++++++++++++++------------ test/snapshot.test.ts | 39 ++++++++++++++++++++- 7 files changed, 209 insertions(+), 80 deletions(-) diff --git a/scripts/build-jdk.sh b/scripts/build-jdk.sh index 548fc17..4a82525 100644 --- a/scripts/build-jdk.sh +++ b/scripts/build-jdk.sh @@ -115,4 +115,16 @@ cp "$font_home/LICENSE" "$out/legal/java.desktop/dejavu-fonts.txt" 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"); +const { readFileSync, readdirSync } = require("node:fs"); +const { join } = require("node:path"); +const h = createHash("sha256"); +const files = readdirSync(".", { recursive: true, withFileTypes: true }).filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)); +for (const f of files.filter((f) => !f.startsWith("lib/image.sha256")).sort()) h.update(f).update(readFileSync(f)); +process.stdout.write(h.digest("hex") + "\n"); +') > "$out/lib/image.sha256.tmp" +mv "$out/lib/image.sha256.tmp" "$out/lib/image.sha256" + echo "built $out ($(du -sh "$out" | cut -f1)) with $modules from JDK $release" diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 2c53bf1..f757af3 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -17,7 +17,7 @@ import { NodeBackend } from "../src/node.ts"; const root = join(import.meta.dirname, ".."); const jdk = join(root, "build", "jdk"); const dist = join(root, "dist"); -if (!existsSync(join(jdk, "lib", "packages.txt"))) throw new Error("run `pnpm jdk` first"); +if (!existsSync(join(jdk, "lib", "image.sha256"))) throw new Error("run `pnpm jdk` first"); rmSync(dist, { recursive: true, force: true }); mkdirSync(join(dist, "samples"), { recursive: true }); @@ -27,7 +27,7 @@ const buildId = createHash("sha256"); for (const f of readdirSync(join(root, "src"), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort()) { buildId.update(f).update(readFileSync(join(root, "src", f))); } -for (const f of ["release", "lib/packages.txt"]) buildId.update(readFileSync(join(jdk, f))); +buildId.update(readFileSync(join(jdk, "lib", "image.sha256"))); const define = { JAVA_WEB_BUILD: JSON.stringify(buildId.digest("hex")) }; for (const [entry, out] of [["api.ts", "java-web.js"], ["worker.ts", "java-web-worker.js"]]) { @@ -52,6 +52,7 @@ await build({ format: "esm", target: "node23", banner: { js: "#!/usr/bin/env node" }, + define, }); cpSync(join(root, "types", "node.d.ts"), join(dist, "node.d.ts")); diff --git a/src/browser/idb.ts b/src/browser/idb.ts index e94c9fe..30e1c11 100644 --- a/src/browser/idb.ts +++ b/src/browser/idb.ts @@ -5,7 +5,9 @@ import type { MemoryBackend, MemoryEntry } from "../fs.ts"; import type { SnapshotImage } from "../snapshot.ts"; -const DB = "java-web", FILES = "files", SNAPSHOTS = "snapshots"; +const DB = "java-web", STORE = "files"; +// its own database, so adding it needs no version change that open pages would block +const SNAPSHOT_DB = "java-web-snapshots"; // snapshot keys, most recently used first const ORDER = "#order"; const KEEP_SNAPSHOTS = 4; @@ -17,29 +19,19 @@ function request(r: IDBRequest): Promise { }); } -// null without IndexedDB (private mode, some workers) -let opened: Promise | null = null; -function db(): Promise { - return (opened ??= (async () => { - if (typeof indexedDB === "undefined") return null; - try { - const open = indexedDB.open(DB, 2); - open.onupgradeneeded = () => { - for (const s of [FILES, SNAPSHOTS]) if (!open.result.objectStoreNames.contains(s)) open.result.createObjectStore(s); - }; - return await request(open); - } catch { - return null; - } - })()); -} - // Loads saved files into backend and keeps saving its changes. Without -// IndexedDB the backend stays memory-only. +// IndexedDB (private mode, some workers) the backend stays memory-only. export async function persist(backend: MemoryBackend): Promise { - const d = await db(); - if (!d) return; - const tx = d.transaction(FILES, "readonly").objectStore(FILES); + if (typeof indexedDB === "undefined") return; + let db: IDBDatabase; + try { + const open = indexedDB.open(DB, 1); + open.onupgradeneeded = () => open.result.createObjectStore(STORE); + db = await request(open); + } catch { + return; + } + const tx = db.transaction(STORE, "readonly").objectStore(STORE); const [keys, values] = await Promise.all([request(tx.getAllKeys()), request(tx.getAll())]); keys.forEach((k, i) => { const v = values[i] as MemoryEntry | "dir"; @@ -51,7 +43,7 @@ export async function persist(backend: MemoryBackend): Promise { let scheduled = false; const flush = () => { scheduled = false; - const store = d.transaction(FILES, "readwrite").objectStore(FILES); + const store = db.transaction(STORE, "readwrite").objectStore(STORE); for (const [path, entry] of pending) { if (entry === null) store.delete(path); else store.put(entry, path); @@ -67,23 +59,39 @@ export async function persist(backend: MemoryBackend): Promise { }; } -// Moves key to the front of the use order, dropping snapshots past the limit; -// image, when given, is stored under key. -async function useSnapshot(key: string, image?: SnapshotImage) { - const d = await db(); - if (!d) return; - const store = d.transaction(SNAPSHOTS, "readwrite").objectStore(SNAPSHOTS); +// null without IndexedDB +let snapshotDb: Promise | null = null; +function openSnapshots(): Promise { + return (snapshotDb ??= (async () => { + if (typeof indexedDB === "undefined") return null; + try { + const open = indexedDB.open(SNAPSHOT_DB, 1); + open.onupgradeneeded = () => open.result.createObjectStore(STORE); + return await request(open); + } catch { + return null; + } + })()); +} + +// Moves key to the front of the use order, dropping snapshots past the limit. +// image, when given, is stored under key; null deletes it. +async function useSnapshot(key: string, image?: SnapshotImage | null) { + const db = await openSnapshots(); + if (!db) return; + const store = db.transaction(STORE, "readwrite").objectStore(STORE); if (image) store.put(image, key); - const order = [key, ...((await request(store.get(ORDER))) as string[] | undefined ?? []).filter((k) => k !== key)]; - for (const k of order.slice(KEEP_SNAPSHOTS)) store.delete(k); + const order = ((await request(store.get(ORDER))) as string[] | undefined ?? []).filter((k) => k !== key); + if (image !== null) order.unshift(key); + for (const k of [...order.slice(KEEP_SNAPSHOTS), ...(image === null ? [key] : [])]) store.delete(k); store.put(order.slice(0, KEEP_SNAPSHOTS), ORDER); } export async function loadSnapshot(key: string): Promise { try { - const d = await db(); - if (!d) return null; - const image = (await request(d.transaction(SNAPSHOTS, "readonly").objectStore(SNAPSHOTS).get(key))) as SnapshotImage | undefined; + const db = await openSnapshots(); + if (!db) return null; + const image = (await request(db.transaction(STORE, "readonly").objectStore(STORE).get(key))) as SnapshotImage | undefined; if (!image) return null; useSnapshot(key).catch(() => {}); return image; @@ -96,3 +104,7 @@ export async function loadSnapshot(key: string): Promise { export function saveSnapshot(image: SnapshotImage) { useSnapshot(image.key, image).catch(() => {}); } + +export function dropSnapshot(key: string) { + useSnapshot(key, null).catch(() => {}); +} diff --git a/src/browser/worker.ts b/src/browser/worker.ts index 96ce2b1..e32d214 100644 --- a/src/browser/worker.ts +++ b/src/browser/worker.ts @@ -3,9 +3,9 @@ // A library VM stays up and serves calls until the page goes away. import { MemoryBackend, VFS } from "../fs.ts"; -import { JVM } from "../jvm.ts"; +import { JVM, SnapshotError } from "../jvm.ts"; import { HttpBackend, addIndex, parsePack } from "./http.ts"; -import { loadSnapshot, persist, saveSnapshot } from "./idb.ts"; +import { dropSnapshot, loadSnapshot, persist, saveSnapshot } from "./idb.ts"; import type { SnapshotImage } from "../snapshot.ts"; import type { LibraryError, LibraryReply } from "../library.ts"; import type { NativeReply } from "../bridge.ts"; @@ -115,25 +115,44 @@ function fileClosed(path: string) { // set by the build: the VM code and JDK image a snapshot is valid for declare const JAVA_WEB_BUILD: string; -// Boot snapshots of this page's VMs, also kept in IndexedDB for later loads. -// A VM changes the image it restores, so each gets a copy. +// Boot snapshots of this page's VMs, the most recently used last, also kept +// in IndexedDB for later loads. A VM changes the image it restores, so each +// gets a copy. const snapshots = new Map(); +const keep = (image: SnapshotImage) => { + snapshots.delete(image.key); + snapshots.set(image.key, image); + for (const k of [...snapshots.keys()].slice(0, -4)) snapshots.delete(k); +}; const snapshotCache = { build: JAVA_WEB_BUILD, load: async (key: string) => { const image = snapshots.get(key) ?? await loadSnapshot(key); if (!image) return null; - snapshots.set(key, image); + keep(image); return structuredClone(image); }, wants: () => true, save: (image: SnapshotImage) => { const copy = structuredClone(image); - snapshots.set(image.key, copy); + keep(copy); saveSnapshot(copy); }, + drop: (key: string) => { + snapshots.delete(key); + dropSnapshot(key); + }, }; +// A snapshot that cannot be restored is dropped, and a second VM boots. +function retried(start: () => Promise): Promise { + return start().catch((e) => { + if (!(e instanceof SnapshotError)) throw e; + console.warn(`java-web: ${e.message}`); + return start(); + }); +} + function newVm(classpath: string[], vmId: number): JVM { return new JVM({ snapshots: snapshotCache, @@ -162,9 +181,11 @@ function newVm(classpath: string[], vmId: number): JVM { } function run(id: number, mode: number, what: string, classpath: string[], args: string[]) { - const vm = newVm(classpath, id); - vms.set(id, vm); - return vm.run(mode, what, args).then((code) => { + return retried(() => { + const vm = newVm(classpath, id); + vms.set(id, vm); + return vm.run(mode, what, args); + }).then((code) => { vms.delete(id); post({ type: "exit", id, code }); }); @@ -204,9 +225,11 @@ self.onmessage = (e: MessageEvent) => { break; } case "library": { - const vm = newVm(m.classpath, m.lib); - vms.set(m.lib, vm); - reply(m.id, vm.startLibrary().then(() => ({ value: undefined }))); + reply(m.id, retried(() => { + const vm = newVm(m.classpath, m.lib); + vms.set(m.lib, vm); + return vm.startLibrary(); + }).then(() => ({ value: undefined }))); break; } case "call": { diff --git a/src/jvm.ts b/src/jvm.ts index 8e8b29c..e72b62c 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -75,6 +75,8 @@ export interface JvmOptions { load: (key: string) => SnapshotImage | null | Promise; wants: (key: string) => boolean; save: (image: SnapshotImage) => void; + // forgets a snapshot that could not be restored + drop: (key: string) => void; }; } @@ -84,6 +86,13 @@ const RESUMED = Object.freeze({ kind: "resume" }) as unknown as Request; // LauncherHelper launch modes export const LM_CLASS = 1, LM_JAR = 2; +// Rejects run and startLibrary when a snapshot could not be restored. The +// snapshot is dropped, so a new VM with the same options boots. +export class SnapshotError extends Error {} + +// properties under which boot reads application files, which a key does not cover +const BOOT_READS_APP = /^(java\.system\.class\.loader|jdk\.module\.(path|upgrade\.path|patch\.\d+))$/; + const YIELD = (() => { if (typeof setImmediate === "function") return (fn: () => void) => setImmediate(fn); const ch = new MessageChannel(); @@ -1039,13 +1048,13 @@ export class JVM { // as the java launcher sets it if (!this.props.has("sun.java.command")) this.props.set("sun.java.command", [what, ...args].join(" ")); const t = new JThread(this); - this.restore(t, (restored) => { + return new Promise((resolve, reject) => this.restore(t, (restored) => { t.pushNative(this.mainGen(t, mode, what, args, restored), null, "V", false); // mainGen halts; ending without it means a VM error killed the thread t.onExit = () => this.halt(1); this.startThread(t); - }); - return this.exited; + resolve(this.exited); + }, reject)); } // Starts the VM with no main class; Java can then be called through @@ -1056,7 +1065,7 @@ export class JVM { this.restore(t, (restored) => { t.pushNative(this.libraryGen(t, restored, resolve, reject), null, "V", false); this.startThread(t); - }); + }, reject); }); } @@ -1145,16 +1154,28 @@ export class JVM { } } + private get snapshots() { + for (const k of this.props.keys()) if (BOOT_READS_APP.test(k)) return undefined; + return this.opts.snapshots; + } + // Loads the snapshot taken after a boot with the same inputs into the VM, // with t as its main thread, then starts it; synchronously when load is. - private restore(t: JThread, start: (restored: boolean) => void) { - const snaps = this.opts.snapshots; + private restore(t: JThread, start: (restored: boolean) => void, failed: (e: SnapshotError) => void) { + const snaps = this.snapshots; if (!snaps) return start(false); const key = snapshotKey(this, snaps.build); const done = (image: SnapshotImage | null) => { - const restored = image?.key === key && this.exitCode === null; - if (restored) restoreVM(this, image, t); - start(restored); + if (image?.key !== key || this.exitCode !== null) return start(false); + try { + restoreVM(this, image, t); + } catch (e) { + // the VM is half restored; a new one boots + this.halt(1); + snaps.drop(key); + return failed(new SnapshotError(`boot snapshot not restored: ${e instanceof Error ? e.message : e}`, { cause: e })); + } + start(true); }; const image = snaps.load(key); if (image instanceof Promise) image.then(done, () => done(null)); @@ -1164,7 +1185,7 @@ export class JVM { // Boots, or finishes a restore: the one property the key leaves out is set // as a boot would have. private *startGen(t: JThread, restored: boolean): NativeGen { - const snaps = this.opts.snapshots; + const snaps = this.snapshots; if (restored) { const cmd = this.props.get("sun.java.command"); const system = this.loadBootOrFail("java/lang/System"); diff --git a/src/node.ts b/src/node.ts index 4414bd6..73339f0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -11,7 +11,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { VFS } from "./fs.ts"; import type { Backend, Stat } from "./fs.ts"; import { Bridge, nativeTable } from "./bridge.ts"; -import { JVM, LM_CLASS, LM_JAR } from "./jvm.ts"; +import { JVM, LM_CLASS, LM_JAR, SnapshotError } from "./jvm.ts"; import type { SnapshotImage } from "./snapshot.ts"; import { hostNatives } from "./hostnatives.ts"; import { ImageBackend } from "./image.ts"; @@ -245,19 +245,27 @@ function defaultSnapshotDir(): string | null { const KEEP_SNAPSHOTS = 16, KEEP_MARKERS = 256; -// Boot snapshots kept in dir, one file per key. The build covers this code -// (every source file when run from src/) and the java.home image. A key's -// snapshot is taken the second time it boots, so one-off setups leave only -// an empty marker. -function snapshotCache(dir: string, vfs: VFS) { - const h = createHash("sha256"); - const self = fileURLToPath(import.meta.url); - const files = self.endsWith(".ts") - ? fs.readdirSync(path.dirname(self), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort().map((f) => path.join(path.dirname(self), f)) - : [self]; - for (const f of files) h.update(f).update(fs.readFileSync(f)); - for (const f of ["/jdk/release", "/jdk/lib/packages.txt"]) h.update(vfs.read(f) ?? new Uint8Array(0)); - const build = h.digest("hex"); +// set by the build of dist/node.js: names its code and image +declare const JAVA_WEB_BUILD: string; + +// What a snapshot is valid for: the VM code (every source file when run from +// src/) and the java.home image, by the digest scripts/build-jdk.sh writes. An +// image without one gets no snapshots. +function buildId(vfs: VFS): string | null { + if (typeof JAVA_WEB_BUILD === "string") return JAVA_WEB_BUILD; + const image = vfs.read("/jdk/lib/image.sha256"); + if (!image) return null; + const h = createHash("sha256").update(image); + const src = path.dirname(fileURLToPath(import.meta.url)); + for (const f of fs.readdirSync(src, { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort()) { + h.update(f).update(fs.readFileSync(path.join(src, f))); + } + return h.digest("hex"); +} + +// Boot snapshots kept in dir, one file per key. A key's snapshot is taken the +// second time it boots, so one-off setups leave only an empty marker. +function snapshotCache(dir: string, build: string) { const name = (key: string) => path.join(dir, createHash("sha256").update(key).digest("hex").slice(0, 32)); const file = (key: string) => name(key) + ".snap"; // keeps the most recently used of each kind @@ -289,6 +297,7 @@ function snapshotCache(dir: string, vfs: VFS) { return (e as NodeJS.ErrnoException).code === "EEXIST"; } }, + drop: (key: string) => fs.rmSync(file(key), { force: true }), save: (image: SnapshotImage) => { const tmp = `${file(image.key)}.${process.pid}.tmp`; try { @@ -322,8 +331,9 @@ function nodeVm(o: NodeRunOptions): JVM { release: (_, ids) => vm.library.release(ids), }); const snapDir = o.snapshotDir === undefined ? defaultSnapshotDir() : o.snapshotDir; + const build = snapDir ? buildId(vfs) : null; const vm: JVM = new JVM({ - ...(snapDir ? { snapshots: snapshotCache(snapDir, vfs) } : {}), + ...(snapDir && build ? { snapshots: snapshotCache(snapDir, build) } : {}), fs: vfs, javaHome: "/jdk", classpath, @@ -367,17 +377,30 @@ function nodeVm(o: NodeRunOptions): JVM { return vm; } +// A snapshot that cannot be restored is dropped, and a second VM boots. +function retried(start: () => Promise): Promise { + return start().catch((e) => { + if (!(e instanceof SnapshotError)) throw e; + process.stderr.write(`java-web: ${e.message}\n`); + return start(); + }); +} + export function runNode(o: NodeRunOptions): Promise { - const vm = nodeVm(o); - return o.jar ? vm.run(LM_JAR, path.resolve(o.jar), o.args ?? []) : vm.run(LM_CLASS, o.mainClass!, o.args ?? []); + return retried(() => { + const vm = nodeVm(o); + return o.jar ? vm.run(LM_JAR, path.resolve(o.jar), o.args ?? []) : vm.run(LM_CLASS, o.mainClass!, o.args ?? []); + }); } // A VM in library mode. libraryOf(vm) is its `lib`, as cheerpjRunLibrary // returns it in the browser; vm.halt stops it. -export async function startLibrary(o: NodeRunOptions): Promise { - const vm = nodeVm(o); - await vm.startLibrary(); - return vm; +export function startLibrary(o: NodeRunOptions): Promise { + return retried(async () => { + const vm = nodeVm(o); + await vm.startLibrary(); + return vm; + }); } export function libraryOf(vm: object): any { diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 44c1bb9..e366a10 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1,7 +1,8 @@ // Boot snapshots: a VM restored from one behaves as one that booted. import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { deserialize, serialize } from "node:v8"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, test } from "node:test"; @@ -28,6 +29,18 @@ async function run(snapshotDir: string | null, props: Record = { return { code, stdout: Buffer.concat(chunks).toString("utf8") }; } +// java-web's own messages go to the process's stderr +async function stderrOf(f: () => Promise): Promise<[T, string]> { + const write = process.stderr.write; + let text = ""; + process.stderr.write = ((s: string | Uint8Array) => { text += s; return true; }) as typeof write; + try { + return [await f(), text]; + } finally { + process.stderr.write = write; + } +} + const files = (dir: string, ext: string) => readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => join(dir, f)); // marks a snapshot unused, so a load shows in its time @@ -89,3 +102,27 @@ test("a snapshot that cannot be saved leaves the run as it was", async () => { assert.deepEqual(await run(dir), expected); assert.deepEqual(files(dir, ".tmp"), []); }); + +test("a snapshot that cannot be restored is dropped and the VM boots", async () => { + const dir = cacheDir(); + const expected = await run(null); + await run(dir); + await run(dir); + const [snap] = files(dir, ".snap"); + const image = deserialize(readFileSync(snap)); + image.classes.at(-1).name = "no/such/Class"; + writeFileSync(snap, serialize(image)); + const [got, err] = await stderrOf(() => run(dir)); + assert.deepEqual(got, expected); + assert.match(err, /boot snapshot not restored/); + // the boot saved a good one in its place + age(snap); + assert.deepEqual(await stderrOf(() => run(dir)), [expected, ""]); + assert.ok(used(snap)); +}); + +test("a module path turns snapshots off, since boot reads its modules", async () => { + const dir = cacheDir(); + for (let i = 0; i < 2; i++) await run(dir, { "jdk.module.path": cacheDir() }); + assert.deepEqual(readdirSync(dir), []); +}); From 2194279c16b1e1c4b976cb3dad5f774ce478f4a3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:42:19 -0600 Subject: [PATCH 05/10] Make the snapshot tests tell a restore from a fresh save and bound the reference queue wait --- test/snapshot.test.ts | 25 +++++++++++++++---------- test/snapshot/Boot.java | 6 ++++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index e366a10..1c3979d 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -43,11 +43,16 @@ async function stderrOf(f: () => Promise): Promise<[T, string]> { const files = (dir: string, ext: string) => readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => join(dir, f)); -// marks a snapshot unused, so a load shows in its time +// Marks a snapshot unused. A load touches the same file, while booting and +// saving again would rename a new one over it. function age(file: string) { utimesSync(file, new Date(0), new Date(0)); + return statSync(file).ino; } -const used = (file: string) => statSync(file).mtimeMs > 0; +const loaded = (file: string, ino: number) => { + const s = statSync(file); + return s.mtimeMs > 0 && s.ino === ino; +}; test("a snapshot is taken on the second boot and restored on the third", async () => { const ref = spawnSync(hotspot("java"), ["-cp", out, "Boot", "a", "b"], { encoding: "utf8" }); @@ -57,9 +62,9 @@ test("a snapshot is taken on the second boot and restored on the third", async ( assert.equal(files(dir, ".snap").length, 0); assert.deepEqual(await run(dir), expected); const [snap] = files(dir, ".snap"); - age(snap); + const ino = age(snap); assert.deepEqual(await run(dir), expected); - assert.ok(used(snap)); + assert.ok(loaded(snap, ino)); }); test("other properties boot without the snapshot", async () => { @@ -67,10 +72,10 @@ test("other properties boot without the snapshot", async () => { await run(dir); await run(dir); const [snap] = files(dir, ".snap"); - age(snap); + const ino = age(snap); const got = await run(dir, { "snap.prop": "x" }); assert.equal(got.stdout.split("\n")[2], "x"); - assert.ok(!used(snap)); + assert.ok(!loaded(snap, ino)); }); test("library mode restores its own snapshot", async () => { @@ -87,9 +92,9 @@ test("library mode restores its own snapshot", async () => { assert.equal(await where(), "null on main"); assert.equal(await where(), "null on main"); assert.equal(files(dir, ".snap").length, 2); - for (const f of files(dir, ".snap")) age(f); + const inos = new Map(files(dir, ".snap").map((f) => [f, age(f)])); assert.equal(await where(), "null on main"); - assert.equal(files(dir, ".snap").filter(used).length, 1); + assert.equal([...inos].filter(([f, ino]) => loaded(f, ino)).length, 1); }); test("a snapshot that cannot be saved leaves the run as it was", async () => { @@ -116,9 +121,9 @@ test("a snapshot that cannot be restored is dropped and the VM boots", async () assert.deepEqual(got, expected); assert.match(err, /boot snapshot not restored/); // the boot saved a good one in its place - age(snap); + const ino = age(snap); assert.deepEqual(await stderrOf(() => run(dir)), [expected, ""]); - assert.ok(used(snap)); + assert.ok(loaded(snap, ino)); }); test("a module path turns snapshots off, since boot reads its modules", async () => { diff --git a/test/snapshot/Boot.java b/test/snapshot/Boot.java index 240b5e5..d9000c9 100644 --- a/test/snapshot/Boot.java +++ b/test/snapshot/Boot.java @@ -1,3 +1,4 @@ +import java.lang.ref.Reference; import java.lang.ref.ReferenceQueue; import java.lang.ref.WeakReference; @@ -10,8 +11,9 @@ public static void main(String[] args) throws Exception { System.out.println(System.getProperty("snap.prop")); ReferenceQueue q = new ReferenceQueue<>(); WeakReference w = new WeakReference<>(new Object(), q); - while (q.remove(10) == null) System.gc(); - System.out.println("enqueued " + (w.get() == null)); + Reference r = null; + for (int i = 0; i < 100 && (r = q.remove(10)) == null; i++) System.gc(); + System.out.println("enqueued " + (r == w)); Thread t = new Thread(() -> System.out.println("thread " + Thread.currentThread().getName()), "worker"); t.start(); t.join(); From 1b1df42d03d6cfafa490992cb62d56c090aa060a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:55:02 -0600 Subject: [PATCH 06/10] Keep run arguments and class bytes out of snapshots after boot, and key a custom JDK image by its own digest --- AGENTS.md | 4 ++-- src/jvm.ts | 41 +++++++++++++++++++++++------------------ src/node.ts | 13 +++++++------ src/snapshot.ts | 4 ++-- test/snapshot.test.ts | 2 ++ 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83a4a64..e365583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ Node, which is how the tests run. - **wire value**: a value as it crosses between the VM and JS (library.ts). - **boot snapshot**: the VM right after the JDK's boot, saved and loaded on later starts with the same key (build, class path, properties, options, - working directory) instead of booting. Kept in `~/.cache/java-web` under - Node and IndexedDB in browsers. + working directory) instead of booting. Kept in `~/.cache/java-web/snapshots` + under Node (docs/node.md) and IndexedDB in browsers. - **Sock**: a socket, event or epoll file descriptor behind the `sun.nio.ch` natives. A backend (host sockets under Node, loopback inside the VM) moves its bytes. diff --git a/src/jvm.ts b/src/jvm.ts index e72b62c..27168ac 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -126,8 +126,8 @@ export class JVM { private hiddenCount = 0; // for java.management: classes defined, threads started and the most alive at once classCount = 0; - // every class in creation order, while recording for a snapshot - readonly created: JClass[] | null; + // every class in creation order, until the snapshot after boot is taken + created: JClass[] | null; threadsStarted = 0; peakThreads = 0; exitCode: number | null = null; @@ -1182,26 +1182,31 @@ export class JVM { else done(image); } - // Boots, or finishes a restore: the one property the key leaves out is set - // as a boot would have. + // Boots, or finishes a restore. Boot does not read sun.java.command, so with + // snapshots on it boots without one: a snapshot never holds a run's arguments. private *startGen(t: JThread, restored: boolean): NativeGen { const snaps = this.snapshots; - if (restored) { - const cmd = this.props.get("sun.java.command"); + const cmd = snaps ? this.props.get("sun.java.command") : undefined; + if (!restored) { + if (cmd !== undefined) this.props.set("sun.java.command", ""); + yield* this.bootGen(t); + // a snapshot is only a cache: failing to make or keep one never fails the run + if (snaps) { + try { + const key = snapshotKey(this, snaps.build); + if (snaps.wants(key)) snaps.save(dumpVM(this, t, key)); + } catch (e) { + this.debug("snapshot not saved:", e); + } + } + } + for (const c of this.created ?? []) c.bytes = undefined; + this.created = null; + if (cmd !== undefined) { + this.props.set("sun.java.command", cmd); const system = this.loadBootOrFail("java/lang/System"); const set = system.methodMap.get("setProperty(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")!; - if (cmd !== undefined) yield t.call(set, this.str("sun.java.command"), this.str(cmd)); - return; - } - yield* this.bootGen(t); - // a snapshot is only a cache: failing to make or keep one never fails the run - if (snaps) { - try { - const key = snapshotKey(this, snaps.build); - if (snaps.wants(key)) snaps.save(dumpVM(this, t, key)); - } catch (e) { - this.debug("snapshot not saved:", e); - } + yield t.call(set, this.str("sun.java.command"), this.str(cmd)); } } diff --git a/src/node.ts b/src/node.ts index 73339f0..9b0c9e8 100644 --- a/src/node.ts +++ b/src/node.ts @@ -248,14 +248,15 @@ const KEEP_SNAPSHOTS = 16, KEEP_MARKERS = 256; // set by the build of dist/node.js: names its code and image declare const JAVA_WEB_BUILD: string; -// What a snapshot is valid for: the VM code (every source file when run from -// src/) and the java.home image, by the digest scripts/build-jdk.sh writes. An -// image without one gets no snapshots. -function buildId(vfs: VFS): string | null { - if (typeof JAVA_WEB_BUILD === "string") return JAVA_WEB_BUILD; +// What a snapshot is valid for: the VM code and the java.home image. The +// packaged image is part of the build's ID; any other image needs the digest +// scripts/build-jdk.sh writes, and gets no snapshots without one. +function buildId(vfs: VFS, packaged: boolean): string | null { + if (packaged) return typeof JAVA_WEB_BUILD === "string" ? JAVA_WEB_BUILD : null; const image = vfs.read("/jdk/lib/image.sha256"); if (!image) return null; const h = createHash("sha256").update(image); + if (typeof JAVA_WEB_BUILD === "string") return h.update(JAVA_WEB_BUILD).digest("hex"); const src = path.dirname(fileURLToPath(import.meta.url)); for (const f of fs.readdirSync(src, { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort()) { h.update(f).update(fs.readFileSync(path.join(src, f))); @@ -331,7 +332,7 @@ function nodeVm(o: NodeRunOptions): JVM { release: (_, ids) => vm.library.release(ids), }); const snapDir = o.snapshotDir === undefined ? defaultSnapshotDir() : o.snapshotDir; - const build = snapDir ? buildId(vfs) : null; + const build = snapDir ? buildId(vfs, packaged) : null; const vm: JVM = new JVM({ ...(snapDir && build ? { snapshots: snapshotCache(snapDir, build) } : {}), fs: vfs, diff --git a/src/snapshot.ts b/src/snapshot.ts index 1b42d5d..c5e982e 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -70,9 +70,9 @@ interface ThreadRec { // What boot depends on: the properties the VM gives Java and the options that // change how boot classes run. A snapshot serves only a VM whose key is the // same. sun.java.command names the main class, which boot does not use; it is -// set again after a restore. +// set after boot. export function snapshotKey(vm: JVM, build: string): string { - // set after a restore; only whether there is one changes the boot + // only whether there is one changes the boot const vmProps = vmProperties(vm); const i = vmProps.indexOf("sun.java.command"); if (i >= 0 && i % 2 === 0) vmProps[i + 1] = ""; diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 1c3979d..ae03615 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -62,6 +62,8 @@ test("a snapshot is taken on the second boot and restored on the third", async ( assert.equal(files(dir, ".snap").length, 0); assert.deepEqual(await run(dir), expected); const [snap] = files(dir, ".snap"); + // nor the arguments of the run that took it + assert.ok(!readFileSync(snap).includes("Boot a b")); const ino = age(snap); assert.deepEqual(await run(dir), expected); assert.ok(loaded(snap, ino)); From 9fa946c0beff0a0cf01412087f086fb52405963d Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:55:42 -0600 Subject: [PATCH 07/10] Note why a snapshot load checks for a halted VM --- src/jvm.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jvm.ts b/src/jvm.ts index 27168ac..add6487 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -1166,6 +1166,7 @@ export class JVM { if (!snaps) return start(false); const key = snapshotKey(this, snaps.build); const done = (image: SnapshotImage | null) => { + // an async load can finish after the VM was halted if (image?.key !== key || this.exitCode !== null) return start(false); try { restoreVM(this, image, t); From 5db2fe19bbbbfe5300196bfbf36cb61f60d01262 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:20 -0600 Subject: [PATCH 08/10] Say where the source digest reads from --- src/node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node.ts b/src/node.ts index 9b0c9e8..5fa7766 100644 --- a/src/node.ts +++ b/src/node.ts @@ -257,6 +257,7 @@ function buildId(vfs: VFS, packaged: boolean): string | null { if (!image) return null; const h = createHash("sha256").update(image); if (typeof JAVA_WEB_BUILD === "string") return h.update(JAVA_WEB_BUILD).digest("hex"); + // unbundled, this file runs as src/node.ts, next to the rest of the VM's sources const src = path.dirname(fileURLToPath(import.meta.url)); for (const f of fs.readdirSync(src, { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts")).sort()) { h.update(f).update(fs.readFileSync(path.join(src, f))); From c9f901e8c5e04e30299dd26c1ecf63aa69c99e9f Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:05:04 -0600 Subject: [PATCH 09/10] Compare snapshot inodes as BigInts in the tests --- test/snapshot.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index ae03615..f75dbf1 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -47,11 +47,11 @@ const files = (dir: string, ext: string) => readdirSync(dir).filter((f) => f.end // saving again would rename a new one over it. function age(file: string) { utimesSync(file, new Date(0), new Date(0)); - return statSync(file).ino; + return statSync(file, { bigint: true }).ino; } -const loaded = (file: string, ino: number) => { - const s = statSync(file); - return s.mtimeMs > 0 && s.ino === ino; +const loaded = (file: string, ino: bigint) => { + const s = statSync(file, { bigint: true }); + return s.mtimeMs > 0n && s.ino === ino; }; test("a snapshot is taken on the second boot and restored on the third", async () => { From 0756dcd334ec2555e979226065ed6cd16c145b16 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:07:17 -0600 Subject: [PATCH 10/10] Keep a dropped browser snapshot from being loaded again before its delete lands --- src/browser/worker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/worker.ts b/src/browser/worker.ts index e32d214..335cde4 100644 --- a/src/browser/worker.ts +++ b/src/browser/worker.ts @@ -124,9 +124,12 @@ const keep = (image: SnapshotImage) => { snapshots.set(image.key, image); for (const k of [...snapshots.keys()].slice(0, -4)) snapshots.delete(k); }; +// the IndexedDB delete is async, so a retry could still read a dropped one +const dropped = new Set(); const snapshotCache = { build: JAVA_WEB_BUILD, load: async (key: string) => { + if (dropped.has(key)) return null; const image = snapshots.get(key) ?? await loadSnapshot(key); if (!image) return null; keep(image); @@ -134,12 +137,14 @@ const snapshotCache = { }, wants: () => true, save: (image: SnapshotImage) => { + dropped.delete(image.key); const copy = structuredClone(image); keep(copy); saveSnapshot(copy); }, drop: (key: string) => { snapshots.delete(key); + dropped.add(key); dropSnapshot(key); }, };