Repository navigation
Expand file tree
/
Copy pathnode.ts
More file actions
521 lines (497 loc) · 19.5 KB
/
Copy pathnode.ts
File metadata and controls
521 lines (497 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
// Node entry: java-web [-cp path] [-Dk=v] [--debug] Main [args] | -jar app.jar [args]
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 { 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, SnapshotError } from "./jvm.ts";
import type { SnapshotImage } from "./snapshot.ts";
import { hostNatives } from "./hostnatives.ts";
import { ImageBackend, imageFd } from "./image.ts";
import { NodeNet } from "./net-node.ts";
import { PendingThrow } from "./thread.ts";
import { setMaxHeap } from "./runtime.ts";
import type { NetBackend } from "./sockets.ts";
// A host directory exposed read-only (or writable) inside the VFS.
const MAX_FILE = 512 << 20;
export class NodeBackend implements Backend {
readonly root: string;
readonly writable: boolean;
constructor(root: string, writable = false) {
this.root = path.resolve(root);
this.writable = writable;
}
// read-only trees, like the JDK image, do not change while a VM runs
private stats = new Map<string, Stat | null>();
private lists = new Map<string, string[] | null>();
// p is a normalized VFS path
private host(p: string) {
return this.root === "/" ? p : this.root + p;
}
resolvePath(p: string): string | null {
if (this.root !== "/") return null;
try {
const name = path.basename(p);
if (name === "." || name === "..") return fs.realpathSync.native(p);
return fs.realpathSync.native(path.dirname(p)) + "/" + name;
} catch {
return null;
}
}
stat(p: string): Stat | null {
if (!this.writable && this.stats.has(p)) return this.stats.get(p)!;
let st: Stat | null = null;
try {
const s = fs.statSync(this.host(p));
st = { dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777 };
} catch {
st = null;
}
if (!this.writable) this.stats.set(p, st);
return st;
}
// Only regular files: readFileSync on a device or pipe can grow without end.
read(p: string) {
try {
const s = fs.statSync(this.host(p));
if (!s.isFile() || s.size > MAX_FILE) return null;
return new Uint8Array(fs.readFileSync(this.host(p)));
} catch {
return null;
}
}
// Other open files keep their buffered contents even if the host removes them.
canReadRange(p: string) { return !this.writable && p === "/lib/modules"; }
// this VM's descriptor of the JDK image
private imageFd: number | undefined;
readRange(p: string, off: number, len: number): Uint8Array | null {
const image = p === "/lib/modules" && !this.writable;
try {
const fd = image ? (this.imageFd ??= imageFd(this.host(p))) : fs.openSync(this.host(p), "r");
try {
if (!image && !fs.fstatSync(fd).isFile()) return null;
const out = new Uint8Array(len);
let n = 0;
while (n < len) {
const got = fs.readSync(fd, out, n, len - n, off + n);
if (!got) break;
n += got;
}
return out.subarray(0, n);
} finally {
if (!image) fs.closeSync(fd);
}
} catch { return null; }
}
list(p: string) {
if (!this.writable && this.lists.has(p)) return this.lists.get(p)!;
let names: string[] | null;
try {
names = fs.readdirSync(this.host(p));
} catch {
names = null;
}
if (!this.writable) this.lists.set(p, names);
return names;
}
// the host's reason for the last failed write, read right after it
private writeFailure: [string, string] | null = null;
write(p: string, data: Uint8Array) {
try {
fs.writeFileSync(this.host(p), data);
this.writeFailure = null;
return true;
} catch (e) {
this.writeFailure = [p, (e as NodeJS.ErrnoException).code ?? "EIO"];
return false;
}
}
writeError(p: string) {
return this.writeFailure?.[0] === p ? this.writeFailure[1] : undefined;
}
denied(p: string) {
const host = this.host(p);
try {
fs.accessSync(fs.existsSync(host) ? host : path.dirname(host), fs.constants.W_OK);
return false;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EACCES" || (e as NodeJS.ErrnoException).code === "EPERM";
}
}
private chmodFailure: [string, string] | null = null;
chmod(p: string, f: (mode: number) => number) {
try {
fs.chmodSync(this.host(p), f(fs.statSync(this.host(p)).mode & 0o7777));
this.chmodFailure = null;
return true;
} catch (e) {
this.chmodFailure = [p, (e as NodeJS.ErrnoException).code ?? "EIO"];
return false;
}
}
chmodError(p: string) {
return this.chmodFailure?.[0] === p ? this.chmodFailure[1] : undefined;
}
access(p: string, mode: number) {
try {
fs.accessSync(this.host(p), mode);
return true;
} catch {
return false;
}
}
space(p: string) {
try {
const s = fs.statfsSync(this.host(p), { bigint: true });
return { bsize: s.bsize, blocks: s.blocks, bfree: s.bfree, bavail: s.bavail };
} catch {
return null;
}
}
setMtime(p: string, ms: number) {
try {
fs.utimesSync(this.host(p), fs.statSync(this.host(p)).atime, ms / 1000);
return true;
} catch {
return false;
}
}
mkdir(p: string) {
try {
fs.mkdirSync(this.host(p));
return true;
} catch {
return false;
}
}
// like unlink or rmdir: a directory must be empty
remove(p: string) {
try {
if (fs.statSync(this.host(p)).isDirectory()) fs.rmdirSync(this.host(p));
else fs.rmSync(this.host(p));
return true;
} catch {
return false;
}
}
rename(a: string, b: string) {
try {
fs.renameSync(this.host(a), this.host(b));
return true;
} catch {
return false;
}
}
}
const here = path.dirname(fileURLToPath(import.meta.url));
export const defaultJdk = path.join(here, "..", "build", "jdk");
export interface NodeRunOptions {
classpath: string[];
mainClass?: string;
jar?: string;
args?: string[];
props?: Record<string, string>;
// a JDK image instead of build/jdk (or, for dist/node.js, the packaged one)
jdk?: string;
debug?: boolean;
stdout?: (b: Uint8Array) => void;
stderr?: (b: Uint8Array) => void;
// fixed stdin contents instead of the process stdin
input?: Uint8Array;
// native methods in JS as in CheerpJ: Java_pkg_Class_method(lib, ...args)
natives?: Record<string, (lib: object, ...args: any[]) => unknown>;
// -ea, -da:pkg..., -esa and the like
assertions?: string[];
// the process exits when its resident memory goes past this
maxRssMb?: number;
enablePreview?: boolean;
// what InetAddress.getLocalHost() reports instead of the host's name
hostName?: string;
// what System.getenv() returns instead of the host's environment
env?: Record<string, string>;
// load jdk.localedata, the formats and names of locales other than English
localeData?: boolean;
// sockets and name lookup; host sockets by default
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) {
let done = false;
return async () => {
if (done) return null;
done = true;
return data;
};
}
const bridges = new WeakMap<object, Bridge>();
// The host file system is the VFS root, with the JDK image mounted at /jdk.
// System.gc() calls globalThis.gc, which node defines only under --expose-gc
function exposeGc() {
const g = globalThis as { gc?: unknown };
if (typeof g.gc === "function") return;
// V8 aborts on a flag change once flags are frozen
if (/(^|\s)--freeze-flags-after-init\b/.test([...process.execArgv, process.env.NODE_OPTIONS ?? ""].join(" "))) return;
setFlagsFromString("--expose-gc");
// absent when --expose-gc-as gave it another name
const gc = runInNewContext("typeof gc === 'function' ? gc : undefined");
if (gc) g.gc = gc;
}
// Once the reader goes away (a closed pipe), writes fail with an IOException as
// on HotSpot; without a listener, node exits on the stream's error event.
const broken = new Set<NodeJS.WriteStream>();
function hostOut(s: NodeJS.WriteStream) {
if (!s.listeners("error").includes(onOutError)) s.on("error", onOutError);
return (b: Uint8Array) => {
if (broken.has(s)) throw new PendingThrow("java/io/IOException", "Broken pipe");
s.write(b);
};
}
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;
// 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 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");
// 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)));
}
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
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";
}
},
drop: (key: string) => fs.rmSync(file(key), { force: true }),
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();
vfs.mount("/", new NodeBackend("/", true));
// built as dist/node.js, it reads the packaged image next to it
const packaged = !o.jdk && fs.existsSync(path.join(here, "boot.pack"));
const jdk = o.jdk ?? defaultJdk;
if (!packaged && !fs.existsSync(path.join(jdk, "lib", "packages.txt"))) {
throw new Error(`no JDK image at ${jdk}: run \`pnpm jdk\` in a checkout, or keep dist/node.js next to boot.pack and jdk/`);
}
vfs.mount("/jdk", packaged ? new ImageBackend(here) : new NodeBackend(jdk));
const toVfs = (p: string) => path.resolve(p);
const classpath = o.jar ? [toVfs(o.jar)] : o.classpath.map(toVfs);
const natives = o.natives ?? {};
const bridge = new Bridge({
call: (_, req) => vm.library.request(req),
release: (_, ids) => vm.library.release(ids),
});
const snapDir = o.snapshotDir === undefined ? defaultSnapshotDir() : o.snapshotDir;
const build = snapDir ? buildId(vfs, packaged) : null;
const vm: JVM = new JVM({
...(snapDir && build ? { snapshots: snapshotCache(snapDir, build) } : {}),
fs: vfs,
javaHome: "/jdk",
localeData: o.localeData,
classpath,
props: { "java.io.tmpdir": os.tmpdir(), "user.home": os.homedir(), "user.name": os.userInfo().username, ...o.props },
cwd: toVfs(process.cwd()),
debug: o.debug,
assertions: o.assertions && parseAssertions(o.assertions),
enablePreview: o.enablePreview,
net: o.net ?? (o.fetch ? undefined : new NodeNet()),
fetch: o.fetch,
hostName: o.hostName,
env: o.env ?? Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined)),
hostNatives: hostNatives(natives, bridge),
// System.loadLibrary("name") imports <java.library.path>/name.js
loadNativeLibrary: async (p) => {
const mod = await import(pathToFileURL(p).href);
Object.assign(natives, nativeTable(mod));
},
stdout: o.stdout ?? hostOut(process.stdout),
stderr: o.stderr ?? hostOut(process.stderr),
stdin: o.input ? fixedInput(o.input) : () => new Promise((resolve) => {
const onData = (d: Buffer) => { cleanup(); resolve(new Uint8Array(d)); };
const onEnd = () => { cleanup(); resolve(null); };
const cleanup = () => { process.stdin.off("data", onData); process.stdin.off("end", onEnd); process.stdin.pause(); };
process.stdin.on("data", onData);
process.stdin.on("end", onEnd);
process.stdin.resume();
}),
});
bridges.set(vm, bridge);
// A runaway must not take the machine down; per-site caps catch most.
const maxRss = (o.maxRssMb ?? 2048) * 2 ** 20;
const watch = setInterval(() => {
const rss = process.memoryUsage.rss();
if (rss <= maxRss) return;
process.stderr.write(`java-web: ${Math.round(rss / 2 ** 20)} MB resident, over the ${maxRss / 2 ** 20} MB limit; exiting\n`);
process.exit(137);
}, 200);
watch.unref();
vm.exited.finally(() => clearInterval(watch));
return vm;
}
// 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;
process.stderr.write(`java-web: ${e.message}\n`);
return start();
});
}
export function runNode(o: NodeRunOptions): Promise<number> {
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 function startLibrary(o: NodeRunOptions): Promise<JVM> {
return retried(async () => {
const vm = nodeVm(o);
await vm.startLibrary();
return vm;
});
}
export function libraryOf(vm: object): any {
return bridges.get(vm)!.root(0);
}
// Module options become the jdk.module.* properties ModuleBootstrap reads,
// numbered from 0 for the repeatable ones, as HotSpot's launcher does.
const MODULE_PROPS: Record<string, [string, boolean]> = {
"--add-exports": ["jdk.module.addexports", true], "--add-opens": ["jdk.module.addopens", true],
"--add-reads": ["jdk.module.addreads", true], "--add-modules": ["jdk.module.addmods", true],
"--patch-module": ["jdk.module.patch", true], "--enable-native-access": ["jdk.module.enable.native.access", true],
"--limit-modules": ["jdk.module.limitmods", false], "--module-path": ["jdk.module.path", false], "-p": ["jdk.module.path", false],
};
export function moduleFlag(o: NodeRunOptions, arg: string, next: string | undefined): boolean {
const eq = arg.indexOf("=");
const name = eq < 0 ? arg : arg.slice(0, eq);
const spec = MODULE_PROPS[name];
if (!spec) return false;
const value = eq < 0 ? next ?? "" : arg.slice(eq + 1);
const props = (o.props ??= {});
if (!spec[1]) props[spec[0]] = value;
else {
let n = 0;
while (`${spec[0]}.${n}` in props) n++;
props[`${spec[0]}.${n}`] = value;
}
return true;
}
// a -Xmx size: bytes, or k, m or g
function heapSize(s: string): number {
const unit = { k: 2 ** 10, m: 2 ** 20, g: 2 ** 30 }[s.slice(-1).toLowerCase()];
return unit ? Number(s.slice(0, -1)) * unit : Number(s);
}
// the java-web command, which dist/cli.js runs
export function main(argv: string[]) {
const o: NodeRunOptions = { classpath: ["."], props: {} };
let i = 0;
for (; i < argv.length; i++) {
const a = argv[i];
if (a === "-cp" || a === "-classpath" || a === "--class-path") o.classpath = argv[++i].split(":");
else if (a.startsWith("-D")) {
const eq = a.indexOf("=");
o.props![a.slice(2, eq < 0 ? undefined : eq)] = eq < 0 ? "" : a.slice(eq + 1);
} else if (a === "--debug") o.debug = true;
// HotSpot options with no meaning here; module flags take a value
else if (moduleFlag(o, a, argv[i + 1])) i += a.includes("=") ? 0 : 1;
else if (isAssertionFlag(a)) (o.assertions ??= []).push(a);
else if (a === "--enable-preview") o.enablePreview = true;
else if (a === "--locale-data") o.localeData = true;
else if (a.startsWith("-Xmx")) {
// x64 HotSpot refuses to start below 2 MB and rounds up to 2 MB steps
const n = /^-Xmx\d+[kmg]?$/i.test(a) ? heapSize(a.slice(4)) : 0;
if (n < 2 ** 21) {
console.error(n === 0 ? `Invalid maximum heap size: ${a}\nError: Could not create the Java Virtual Machine.\nError: A fatal exception has occurred. Program will exit.`
: "Error occurred during initialization of VM\nToo small maximum heap");
process.exit(1);
}
setMaxHeap(Math.ceil(n / 2 ** 21) * 2 ** 21);
}
else if (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue;
else if (a === "--jdk") o.jdk = argv[++i];
else if (a === "--max-rss") o.maxRssMb = Number(argv[++i]);
else if (a === "-jar") { o.jar = argv[++i]; i++; break; }
else { o.mainClass = a; i++; break; }
}
o.args = argv.slice(i);
if (!o.mainClass && !o.jar) {
console.error("usage: java-web [-cp path] [-Dkey=value] Main [args] | -jar app.jar [args]");
process.exit(2);
}
// Collecting first: node's exit teardown can deadlock while it frees very
// large array buffers (seen on x64 CI runners with a few hundred MB left).
Promise.resolve().then(() => runNode(o)).then((code) => (globalThis.gc?.(), process.exit(code)), (e) => {
console.error(`java-web: ${(e as Error).message}`);
process.exit(1);
});
}
// run as a script, also through the symlink npm puts in node_modules/.bin
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) main(process.argv.slice(2));