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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/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.
5 changes: 5 additions & 0 deletions docs/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions docs/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions scripts/build-jdk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 12 additions & 1 deletion scripts/build-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,13 +17,22 @@ 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 });

// 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)));
}
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"]]) {
await build({
define,
entryPoints: [join(root, "src", "browser", entry)],
outfile: join(dist, out),
bundle: true,
Expand All @@ -42,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"));

Expand Down
61 changes: 59 additions & 2 deletions src/browser/idb.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
// 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";
// 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;

function request<T>(r: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -51,3 +58,53 @@ export async function persist(backend: MemoryBackend): Promise<void> {
}
};
}

// null without IndexedDB
let snapshotDb: Promise<IDBDatabase | null> | null = null;
function openSnapshots(): Promise<IDBDatabase | null> {
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 = ((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<SnapshotImage | null> {
try {
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;
} 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(() => {});
}

export function dropSnapshot(key: string) {
useSnapshot(key, null).catch(() => {});
}
68 changes: 60 additions & 8 deletions src/browser/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
// 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 { persist } 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";
import type { FromWorker, InitMessage, ToWorker } from "./protocol.ts";
Expand Down Expand Up @@ -111,8 +112,55 @@ 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, 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<string, SnapshotImage>();
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);
};
// the IndexedDB delete is async, so a retry could still read a dropped one
const dropped = new Set<string>();
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);
return structuredClone(image);
},
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);
},
};

// A snapshot that cannot be restored is dropped, and a second VM boots.
function retried<T>(start: () => Promise<T>): Promise<T> {
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,
fs: vfs!,
javaHome: "/jdk",
classpath,
Expand All @@ -138,9 +186,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 });
});
Expand Down Expand Up @@ -180,9 +230,11 @@ self.onmessage = (e: MessageEvent<ToWorker>) => {
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": {
Expand Down
Loading
Loading