From bb0acedb8919912099d7160bc5d30fb00367ff8a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:14:09 -0600 Subject: [PATCH 1/3] Save the JIT's generated code in warm snapshots and compile it on each method's first call after a restore --- docs/browser.md | 2 +- docs/node.md | 4 +++- src/jit.ts | 19 ++++++++++++++++- src/jvm.ts | 11 ++++++++++ src/snapshot.ts | 25 ++++++++++++++++++++++ test/warm-jit.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/warm-jit.test.ts diff --git a/docs/browser.md b/docs/browser.md index 58a1e72..1e5ca30 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -135,7 +135,7 @@ compile loads over a thousand) and no library handles are live. Later library starts with the same class path restore that image, after checking that the files, missing paths and directory listings those calls used are unchanged; the first javac compile after it took 2.7 s instead of 5.3 s in Chromium on a -Raspberry Pi 5. An image is about 18 MB. IndexedDB keeps the 2 most recent warm images separately +Raspberry Pi 5. An image is about 21 MB; it holds the JS the JIT generated for the methods compiled so far, and a restored method compiles that code on its first call. IndexedDB keeps the 2 most recent warm images separately from boot snapshots. With `debug: true`, the console says whether a VM was saved, skipped as busy (with the reason) or restored, and why a restore missed. diff --git a/docs/node.md b/docs/node.md index bb3e525..08e18c9 100644 --- a/docs/node.md +++ b/docs/node.md @@ -75,7 +75,9 @@ compile loads over a thousand) and no library handles are live. A later library start with the same class path and working directory restores it, after checking that the files, missing paths and directory listings those calls used are unchanged; a javac compile from it takes about 1 s instead of 6 s on a -Raspberry Pi 5. An image is about 18 MB; warm snapshots share the snapshot +Raspberry Pi 5. An image is about 21 MB, as it also holds the JS the JIT generated for the +methods compiled so far; a restored method compiles that code on its first +call instead of generating it again. Warm snapshots share the snapshot directory and keep the 2 most recent. They build on boot snapshots, so with `snapshotDir: null` there are none. diff --git a/src/jit.ts b/src/jit.ts index 255f21a..8ea8e01 100644 --- a/src/jit.ts +++ b/src/jit.ts @@ -22,6 +22,7 @@ import { memberTarget } from "./natives/invoke.ts"; import { concatStrings } from "./natives/strings.ts"; import { Frame, JavaThrow, NativeFrame, PendingThrow, fromNative } from "./thread.ts"; import type { JThread } from "./thread.ts"; +import type { JVM } from "./jvm.ts"; export const RET = 0, SUSPEND = 1; export type Compiled = (t: JThread, f: Frame) => number; @@ -64,10 +65,26 @@ export function noteBackedge(t: JThread, m: JMethod) { if (m.hot >= BACKEDGE_THRESHOLD && m.jit === null && !m.noJit) compile(t, m); } +export interface SavedJit { src: string; consts: any[] } + +// Gives m the code saved with a warm snapshot, compiled on its first call. +export function restoreJit(vm: JVM, m: JMethod, code: SavedJit) { + if (disabled || noInline || stats) return; + vm.jitCode.set(m, code); + m.hot = THRESHOLD - 1; +} + function compile(t: JThread, m: JMethod) { if (disabled || m.noJit || !m.code || m.name === "" || m.code.code.length > MAX_CODE) return; + const vm = t.vm; try { - const { src, consts } = generate(t, m); + let code = vm.keepJit ? undefined : vm.jitCode.get(m); + if (code) vm.jitCode.delete(m); + else { + code = generate(t, m); + if (vm.keepJit && !stats && !noInline) vm.jitCode.set(m, code); + } + const { src, consts } = code; if (dump && m.toString().includes(dump)) console.error(`// ${m}\n${src}`); m.jit = new Function("rt", "K", src)(runtime, consts) as Compiled; if (stats) stats.set(`compiled ${m}`, src.length); diff --git a/src/jvm.ts b/src/jvm.ts index 4e8359c..070ccbb 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -25,6 +25,7 @@ import { hostNative, jniNames } from "./hostnatives.ts"; import { Library } from "./library.ts"; import { dumpVM, restoreVM, snapshotKey } from "./snapshot.ts"; import type { SnapshotImage, WarmSnapshotImage } from "./snapshot.ts"; +import type { SavedJit } from "./jit.ts"; import type { LibraryRequest } from "./library.ts"; import type { HostNative } from "./hostnatives.ts"; import { PROXY_HOST, PROXY_PORT } from "./http-proxy.ts"; @@ -143,6 +144,10 @@ export class JVM { classCount = 0; // every class in creation order, until the snapshot after boot is taken created: JClass[] | null; + // Generated JS of compiled methods: kept until the warm snapshot is saved, + // then (after a warm restore) compiled on each method's first call. + keepJit: boolean; + jitCode = new Map(); threadsStarted = 0; peakThreads = 0; exitCode: number | null = null; @@ -159,6 +164,7 @@ export class JVM { constructor(opts: JvmOptions) { this.created = opts.snapshots ? [] : null; + this.keepJit = !!(opts.warmSnapshot && opts.warmSnapshots && opts.snapshots); this.opts = opts; this.fs = opts.fs; const extra = ipv4Literal(opts.hostName); @@ -1153,6 +1159,8 @@ export class JVM { if (!trace) { this.fs.endWarmTrace(); this.warmTraceActive = false; + this.keepJit = false; + this.jitCode.clear(); this.debug("warm snapshot busy: access record too large"); return; } @@ -1164,6 +1172,8 @@ export class JVM { this.fs.endWarmTrace(); this.warmTraceActive = false; this.warmSaved = true; + this.keepJit = false; + this.jitCode.clear(); for (const c of this.created ?? []) c.bytes = undefined; this.created = null; this.debug("warm snapshot saved"); @@ -1259,6 +1269,7 @@ export class JVM { return false; } try { + this.keepJit = false; restoreVM(this, image, t); this.warmRestored = true; this.warmSaved = true; diff --git a/src/snapshot.ts b/src/snapshot.ts index d2661dd..bb5dd0c 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -12,6 +12,7 @@ import { MAX_HEAP } from "./runtime.ts"; import type { WarmTrace } from "./fs.ts"; import { reopenSnapshotFile, OpenFile, streams } from "./natives/io.ts"; import { JArray, JClass, JField, JMethod, JObject, Monitor } from "./runtime.ts"; +import { restoreJit } from "./jit.ts"; import { Frame, JThread, NativeFrame, STATUS_RUNNABLE } from "./thread.ts"; import type { NativeGen } from "./thread.ts"; @@ -48,6 +49,8 @@ interface ClassRec { nestHost: unknown; links: [string, unknown][]; caches: [string, unknown][]; + // generated JS and constants of compiled methods, in warm snapshots + jit?: [string, string, unknown][]; } interface VmRec { @@ -112,6 +115,18 @@ class Encoder { private work: (() => void)[] = []; + // enc, or null with nothing recorded when v cannot be saved + tryEnc(v: any, path: string): any { + const before = this.twins.size; + try { + return this.enc(v, path); + } catch { + this.work.length = 0; + [...this.twins.keys()].slice(before).forEach((k) => this.twins.delete(k)); + return null; + } + } + // the twin of v; containers are filled in by drain, so deep object graphs // need no deep recursion enc(v: any, path: string): any { @@ -271,6 +286,15 @@ export function dumpVM(vm: JVM, main: JThread, key: string, library = false): Sn links: c.methods.filter((m) => m.link).map((m) => [m.key, e.enc(m.link, `${p}.${m.key}.link`)]), caches: c.methods.filter((m) => m.cache).map((m) => [m.key, e.enc(m.cache, `${p}.${m.key}.cache`)]), }; + if (library) { + const jit: [string, string, unknown][] = []; + for (const m of c.methods) { + const code = m.jit && vm.jitCode.get(m); + const consts = code && e.tryEnc(code.consts, `${p}.${m.key}.jit`); + if (code && consts) jit.push([m.key, code.src, consts]); + } + if (jit.length) rec.jit = jit; + } 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); @@ -397,6 +421,7 @@ export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { c.nestHost = d.dec(r.nestHost); for (const [k, l] of r.links) c.methodMap.get(k)!.link = d.dec(l); for (const [k, cache] of r.caches ?? []) c.methodMap.get(k)!.cache = d.dec(cache); + for (const [k, src, consts] of r.jit ?? []) restoreJit(vm, c.methodMap.get(k)!, { src, consts: d.dec(consts) }); }); d.flush(); const v = image.vm; diff --git a/test/warm-jit.test.ts b/test/warm-jit.test.ts new file mode 100644 index 0000000..f0fcd38 --- /dev/null +++ b/test/warm-jit.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startLibrary } from "../src/node.ts"; +import { hotspot } from "../scripts/hotspot.ts"; + +// Own file: a warm image restores reliably only in a process that has run no other VM. +const root = join(import.meta.dirname, ".."); +const classes = join(root, "build", "test-warm-snapshot"); +mkdirSync(classes, { recursive: true }); +execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "warm-snapshot", "WarmJavac.java")]); + +async function compile(cache: string, args: string[]) { + const vm = await startLibrary({ classpath: [classes], snapshotDir: cache, warmSnapshot: true }); + try { + const before = vm.jitCode.size; + const result = await vm.callStatic("WarmJavac", "compile", args); + return { result, before, after: vm.jitCode.size }; + } finally { + vm.halt(0); + } +} + +test("a warm restore compiles methods from their saved code on first call", async () => { + const dir = mkdtempSync(join(tmpdir(), "java-web-warm-jit-")); + try { + const src = join(dir, "Result.java"); + writeFileSync(src, "public class Result { public static int value() { return 7; } }\n"); + const reference = join(dir, "reference"); + mkdirSync(reference); + execFileSync(hotspot("javac"), ["-g:none", "-d", reference, src]); + const expected = readFileSync(join(reference, "Result.class")).toString("base64"); + const args = [src, join(dir, "not-created", "Absent.class"), join(dir, "out")]; + + const first = await compile(dir, args); + assert.equal(first.result, expected); + assert.ok(first.before < 100, "the first run has no saved code"); + + const restored = await compile(dir, args); + assert.equal(restored.result, expected); + assert.ok(restored.before > 100, `saved code for ${restored.before} methods`); + assert.ok(restored.after < restored.before / 2, `${restored.after} of ${restored.before} saved methods were not compiled`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 0644b59712b56834eafecbfad546a3cc2044f93a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:38:26 -0600 Subject: [PATCH 2/3] Bound the nesting of snapshot images so v8 serialize and deserialize cannot overflow the stack --- src/jvm.ts | 6 +- src/snapshot.ts | 278 +++++++++++++++++++++++---------- test/snapshot-depth.test.ts | 96 ++++++++++++ test/snapshot-depth/Chain.java | 28 ++++ 4 files changed, 321 insertions(+), 87 deletions(-) create mode 100644 test/snapshot-depth.test.ts create mode 100644 test/snapshot-depth/Chain.java diff --git a/src/jvm.ts b/src/jvm.ts index 763668b..5070883 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -23,7 +23,7 @@ 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 { dumpVM, restoreVM, snapshotKey, SNAPSHOT_FORMAT } from "./snapshot.ts"; import type { SnapshotImage, WarmSnapshotImage } from "./snapshot.ts"; import type { LibraryRequest } from "./library.ts"; import type { HostNative } from "./hostnatives.ts"; @@ -1249,7 +1249,7 @@ export class JVM { const build = snaps.build; this.warmKey = JSON.stringify([snapshotKey(this, build), this.opts.classpath ?? [], this.opts.cwd ?? "/", this.props.get("sun.java.command") ?? null]); const useWarm = (image: WarmSnapshotImage | null) => { - if (!image) return false; + if (image?.format !== SNAPSHOT_FORMAT) return false; let mismatch: string | null; try { mismatch = image.warmKey === this.warmKey && image.key === snapshotKey(this, build) ? this.fs.warmTraceMismatch(image.trace) : "key changed"; @@ -1291,7 +1291,7 @@ export class JVM { 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); + if (image?.format !== SNAPSHOT_FORMAT || image.key !== key || this.exitCode !== null) return start(false); try { restoreVM(this, image, t); } catch (e) { diff --git a/src/snapshot.ts b/src/snapshot.ts index d2661dd..8abd659 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -15,8 +15,13 @@ 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"; +// Bump when the layout of an image changes; images of another format are ignored. +export const SNAPSHOT_FORMAT = 2; + export interface SnapshotImage { + format: number; key: string; + table: unknown[]; classes: ClassRec[]; vm: VmRec; threads: ThreadRec[]; @@ -94,8 +99,26 @@ 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", "continuations"]); +// V8's serializer and deserializer recurse on nesting, so the image must stay +// shallow whatever the Java heap holds. Each object is written in place once, +// inside the first thing that refers to it, as far as NEST levels down; a deeper +// object, and any later reference to one, goes by its index in the table of +// named objects. A list of values holds primitives, typed arrays and objects +// written in place; its references are [position, index] pairs in a second list. +// An object with no objects inside it (a string, a class) adds one level +// wherever it is, so it is shared directly. +const NEST = 64; + +interface Values { + v: unknown[]; + r?: number[]; +} + class Encoder { - private twins = new Map(); + readonly table: unknown[] = []; + private twins = new Map(); + private named = new Map(); + private leaves = new Set(); private classIndex = new Map(); private threadIndex = new Map(); @@ -112,140 +135,227 @@ class Encoder { private work: (() => void)[] = []; - // the twin of v; containers are filled in by drain, so deep object graphs - // need no deep recursion + // The twin of v, or a reference to it if it has one already. 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); + const t = this.child(v, path, 0); while (this.work.length) this.work.pop()!(); return t; } - private twin(v: any, path: string): any { + private name(t: unknown): number { + let i = this.named.get(t); + if (i === undefined) { + i = this.table.length; + this.table.push(t); + this.named.set(t, i); + } + return i; + } + + private child(v: any, path: string, depth: number): any { if (v === null || (typeof v !== "object" && typeof v !== "function")) return v; + if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) return v; const memo = this.twins.get(v); - if (memo !== undefined) return memo; + if (memo !== undefined) return this.leaves.has(memo) ? memo : { $: "R", i: this.name(memo) }; + const t = this.make(v, path, depth); + return depth >= NEST && !this.leaves.has(t) ? { $: "R", i: this.name(t) } : t; + } + + // src's elements as children at depth + private values(src: ArrayLike, path: string, depth: number): Values { + const n = src.length; + const v = new Array(n); + let r: number[] | undefined; + for (let i = 0; i < n; i++) { + const x: any = src[i]; + if (x === null || (typeof x !== "object" && typeof x !== "function")) { + if (x !== undefined || i in (src as object)) v[i] = x; + } else if (ArrayBuffer.isView(x) || x instanceof ArrayBuffer) v[i] = x; + else { + const memo = this.twins.get(x); + if (memo !== undefined) { + if (this.leaves.has(memo)) v[i] = memo; + else { + (r ??= []).push(i, this.name(memo)); + v[i] = null; + } + } else { + const t = this.make(x, path, depth); + if (depth >= NEST && !this.leaves.has(t)) { + (r ??= []).push(i, this.name(t)); + v[i] = null; + } else v[i] = t; + } + } + } + return r ? { v, r } : { v }; + } + + private isLeaf(v: JObject): boolean { + if (v.mon || v.x !== undefined) return false; + const list = v instanceof JArray ? v.a : v.f; + if (!Array.isArray(list)) return true; + for (const e of list) if (e !== null && (typeof e === "function" || (typeof e === "object" && !ArrayBuffer.isView(e) && !(e instanceof ArrayBuffer)))) return false; + return true; + } + + private make(v: any, path: string, depth: number): any { 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; + const next = depth + 1; let t: any; if (v instanceof JObject) { - t = { $: v instanceof JArray ? "A" : "O", c: this.cls(v.cls, path), h: v.hash }; + const array = v instanceof JArray; + t = { $: array ? "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); + if (array && !Array.isArray(v.a)) t.a = v.a; + else { + const { v: list, r } = this.values(array ? (v.a as unknown[]) : v.f, path, next); + t[array ? "a" : "f"] = list; + if (r) t.r = r; + } + if (v.mon) t.n = this.child(v.mon, path, next); + if (v.x !== undefined) t.x = v.x instanceof WeakRef ? { $: "W", v: this.child(v.x.deref() ?? null, path, next) } : this.child(v.x, path, next); }); } 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 }; + const k = this.threadIndex.get(v); + if (k === undefined) throw new Error(`snapshot: unknown thread in ${path}`); + t = { $: "T", i: k }; } 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))); + this.work.push(() => (t.o = this.child(v.owner, path, next))); } 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)); }); + t = { $: "m" }; + this.work.push(() => { + const flat: unknown[] = []; + for (const [k, x] of v) flat.push(k, x); + Object.assign(t, this.values(flat, path, next)); + }); } else if (v instanceof Set) { - t = new Set(); - this.work.push(() => { for (const x of v) t.add(this.twin(x, path)); }); + t = { $: "s" }; + this.work.push(() => Object.assign(t, this.values([...v], path, next))); } else if (Array.isArray(v)) { - t = new Array(v.length); - this.work.push(() => v.forEach((x, i) => (t[i] = this.twin(x, path)))); + t = { $: "L" }; + this.work.push(() => Object.assign(t, this.values(v, path, next))); } 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); }); + t = { $: "P" }; + this.work.push(() => { + const keys = Object.keys(v); + const { v: list, r } = this.values(keys.map((k) => v[k]), path, next); + const o: any = {}; + keys.forEach((k, j) => (o[k] = list[j])); + t.v = o; + if (r) t.r = r.map((x, j) => (j % 2 === 0 ? keys[x] : x)); + }); } this.twins.set(v, t); + if (!(v instanceof JObject) ? v instanceof JClass || v instanceof JMethod || v instanceof JField || v instanceof JThread : this.isLeaf(v)) this.leaves.add(t); return t; } } class Decoder { - private twins = new Map(); - private fills: (() => void)[] = []; + private made = new Map(); + private fills: any[] = []; readonly references: [JObject, unknown][] = []; readonly classes: JClass[]; private threads: JThread[]; + private table: any[]; - constructor(classes: JClass[], threads: JThread[]) { + constructor(table: any[], classes: JClass[], threads: JThread[]) { + this.table = table; this.classes = classes; this.threads = threads; } - dec(v: any): any { - if (v === null || typeof v !== "object") return v; - const memo = this.twins.get(v); + // The value for x, a field of a record. Objects are made at once and filled + // by flush. The image is changed in place, so a VM needs its own copy. + dec(x: any): any { + if (x === null || typeof x !== "object" || ArrayBuffer.isView(x) || x instanceof ArrayBuffer) return x; + const v = x.$ === "R" ? this.table[x.i] : x; + const memo = this.made.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; - } + let o: any; 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 = v.$ === "A" ? new JArray(cls, v.a) : new JObject(cls, v.f); 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; + break; } 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; + case "N": + o = new Monitor(); + o.count = v.n; + break; + case "L": o = v.v; break; + case "m": o = new Map(); break; + case "s": o = new Set(); break; + case "P": o = {}; break; + default: throw new Error(`snapshot: unknown record ${JSON.stringify(v).slice(0, 80)}`); + } + this.made.set(v, o); + this.fills.push(v); + return o; + } + + private fill(v: any) { + const o = this.made.get(v); + if (v.$ === "N") { + o.owner = this.dec(v.o); + return; + } + if (v.$ === "P") { + for (const k of Object.keys(v.v)) o[k] = this.dec(v.v[k]); + if (v.r) for (let k = 0; k < v.r.length; k += 2) o[v.r[k]] = this.dec(this.table[v.r[k + 1]]); + return; + } + const list: unknown[] | undefined = v.$ === "O" ? v.f : v.$ === "A" ? (Array.isArray(v.a) ? v.a : undefined) : v.v; + if (list) { + for (let i = 0; i < list.length; i++) { + const x: any = list[i]; + if (x !== null && typeof x === "object" && !ArrayBuffer.isView(x) && !(x instanceof ArrayBuffer)) list[i] = this.dec(x); } + if (v.r) for (let k = 0; k < v.r.length; k += 2) list[v.r[k]] = this.dec(this.table[v.r[k + 1]]); + } + switch (v.$) { + case "O": case "A": + 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); + } + break; + case "m": + for (let k = 0; k < list!.length; k += 2) o.set(list![k], list![k + 1]); + break; + case "s": + for (const x of list!) o.add(x); + break; } - 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()!(); + while (this.fills.length) this.fill(this.fills.pop()); + } + + // dec, then flush, for a value used at once + now(x: any): any { + const o = this.dec(x); + this.flush(); + return o; } } @@ -338,7 +448,7 @@ export function dumpVM(vm: JVM, main: JThread, key: string, library = false): Sn }; }; const threads = others.map((t) => threadRec(t)); - return { key, classes: classRecs, vm: vmRec, threads: [library ? threadRec(main, true) : { obj: e.enc(main.obj, "main") } as ThreadRec, ...threads] }; + return { format: SNAPSHOT_FORMAT, key, table: e.table, classes: classRecs, vm: vmRec, threads: [library ? threadRec(main, true) : { obj: e.enc(main.obj, "main") } as ThreadRec, ...threads] }; } function snapshotFds(value: unknown): unknown { @@ -368,7 +478,7 @@ 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); + const d = new Decoder(image.table as any[], classes, threads); for (const r of image.classes) { let c: JClass | null; if (r.kind === "prim") c = vm.primClass(r.desc!); @@ -400,14 +510,14 @@ export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { }); d.flush(); const v = image.vm; - const boot = d.dec(v.boot) as Map; + const boot = d.now(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); + for (const [k, p] of d.now(v.bootPackages) as Map) vm.bootPackages.set(k, p); + for (const [k, m] of d.now(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); + for (const [k, s] of d.now(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) { @@ -472,7 +582,7 @@ export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { } }); d.flush(); - restorePending(vm, d.dec(v.pending)); + restorePending(vm, d.now(v.pending)); for (const [o, referent] of d.references) { const r = d.dec(referent); if (r) restoreReference(vm, o, r); diff --git a/test/snapshot-depth.test.ts b/test/snapshot-depth.test.ts new file mode 100644 index 0000000..6e63ccc --- /dev/null +++ b/test/snapshot-depth.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deserialize } from "node:v8"; +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { startLibrary } from "../src/node.ts"; +import { hotspot } from "../scripts/hotspot.ts"; + +const root = join(import.meta.dirname, ".."); +const classes = join(root, "build", "test-snapshot-depth"); +mkdirSync(classes, { recursive: true }); +execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "snapshot-depth", "Chain.java"), join(root, "test", "warm-snapshot", "WarmJavac.java")]); + +const dirs: string[] = []; +after(() => dirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }))); + +const NODES = 200_000; + +// the longest path V8 would recurse through when serializing or restoring value +function nesting(value: unknown): number { + const seen = new Set(); + let max = 0; + const stack: { it: Iterator; depth: number }[] = []; + const enter = (x: unknown, depth: number) => { + if (x === null || typeof x !== "object" || seen.has(x) || ArrayBuffer.isView(x) || x instanceof ArrayBuffer) return; + seen.add(x); + max = Math.max(max, depth); + const kids = x instanceof Map ? [...x.keys(), ...x.values()] : x instanceof Set ? [...x] : Array.isArray(x) ? x : Object.values(x); + stack.push({ it: kids[Symbol.iterator](), depth }); + }; + enter(value, 1); + while (stack.length) { + const top = stack[stack.length - 1]; + const next = top.it.next(); + if (next.done) stack.pop(); + else enter(next.value, top.depth + 1); + } + return max; +} + +async function run(cache: string, work: string, warm: boolean, build: boolean) { + let log = ""; + let settled: () => void = () => {}; + const done = new Promise((resolve) => (settled = resolve)); + const error = console.error; + console.error = (...args: unknown[]) => { + log += `${args.join(" ")}\n`; + if (/warm snapshot (saved|busy|restored)/.test(log)) settled(); + }; + let vm: Awaited> | undefined; + try { + vm = await startLibrary({ classpath: [classes], snapshotDir: cache, warmSnapshot: warm, debug: true }); + if (build) await vm.callStatic("Chain", "build", [NODES]); + const check = await vm.callStatic("Chain", "check", []); + if (warm) { + await vm.callStatic("WarmJavac", "compile", [join(work, "Result.java"), join(work, "absent", "A.class"), join(work, "out")]); + await done; + } + return { check, log }; + } finally { + vm?.halt(0); + console.error = error; + } +} + +test("snapshots of a heap with a 200,000 node linked list stay shallow and restore", async () => { + const dir = mkdtempSync(join(tmpdir(), "java-web-depth-")); + dirs.push(dir); + const cache = join(dir, "cache"); + mkdirSync(cache); + writeFileSync(join(dir, "Result.java"), "public class Result { public static int value() { return 7; } }\n"); + const sum = ((NODES - 1) * NODES) / 2; + const expected = `${NODES} ${NODES} ${sum}`; + + const saved = await run(cache, dir, true, true); + assert.equal(saved.check, expected); + assert.match(saved.log, /warm snapshot saved/); + const [warmFile] = readdirSync(cache).filter((f) => f.endsWith(".warm")); + assert.ok(warmFile, saved.log); + assert.ok(nesting(deserialize(readFileSync(join(cache, warmFile)))) < 400); + + // a second VM in this process restores it, rather than booting + const restored = await run(cache, dir, true, false); + assert.match(restored.log, /warm snapshot restored/); + assert.equal(restored.check, expected); + + // boot snapshots, taken on a key's second boot without warm snapshots + await run(cache, dir, false, false); + await run(cache, dir, false, false); + const [bootFile] = readdirSync(cache).filter((f) => f.endsWith(".snap")); + assert.ok(bootFile); + assert.ok(nesting(deserialize(readFileSync(join(cache, bootFile)))) < 400); + assert.equal((await run(cache, dir, false, true)).check, expected); +}); diff --git a/test/snapshot-depth/Chain.java b/test/snapshot-depth/Chain.java new file mode 100644 index 0000000..931a41b --- /dev/null +++ b/test/snapshot-depth/Chain.java @@ -0,0 +1,28 @@ +public class Chain { + static final class Node { + Node next, prev; + int value; + } + + static Node head, tail; + + public static int build(int n) { + for (int i = 0; i < n; i++) { + Node x = new Node(); + x.value = i; + x.prev = tail; + if (tail == null) head = x; else tail.next = x; + tail = x; + } + return n; + } + + // walks forward summing values, then backward counting + public static String check() { + long sum = 0; + int forward = 0, backward = 0; + for (Node x = head; x != null; x = x.next) { sum += x.value; forward++; } + for (Node x = tail; x != null; x = x.prev) backward++; + return forward + " " + backward + " " + sum; + } +} From c6daf07fe753ce168956e434aee5f85a15385d61 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:38:26 -0600 Subject: [PATCH 3/3] Make the warm snapshot test wait for the save that follows a library call --- test/warm-snapshot.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/warm-snapshot.test.ts b/test/warm-snapshot.test.ts index c875f1b..587f894 100644 --- a/test/warm-snapshot.test.ts +++ b/test/warm-snapshot.test.ts @@ -24,12 +24,20 @@ function scratch() { async function compile(cache: string, source: string, missing: string) { const out = join(cache, "out"); let log = ""; + let settled: () => void = () => {}; + const done = new Promise((resolve) => (settled = resolve)); const error = console.error; - console.error = (...args: unknown[]) => { log += `${args.join(" ")}\n`; }; + console.error = (...args: unknown[]) => { + log += `${args.join(" ")}\n`; + if (/warm snapshot (saved|busy|restored)/.test(log)) settled(); + }; let vm: Awaited> | undefined; try { vm = await startLibrary({ classpath: [classes], snapshotDir: cache, warmSnapshot: true, debug: true }); - return { result: await vm.callStatic("WarmJavac", "compile", [source, missing, out]), log }; + const result = await vm.callStatic("WarmJavac", "compile", [source, missing, out]); + // the VM saves after the call has returned, when it goes idle + await done; + return { result, log }; } finally { vm?.halt(0); console.error = error;