diff --git a/README.md b/README.md index d0aa6d1..81698f1 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ See [docs/node.md](docs/node.md). `BufferedImage`, with HotSpot's pixels except for text. - Sockets: real ones under Node; in the browser, loopback inside the VM and `http:`/`https:` through `fetch`. -- Not yet: AWT and Swing windows, child processes, IPv6. +- Child processes under Node, through the host's own programs. +- Not yet: AWT and Swing windows, child processes in the browser, IPv6. Details: [docs/support.md](docs/support.md). diff --git a/docs/support.md b/docs/support.md index 554e651..153c73f 100644 --- a/docs/support.md +++ b/docs/support.md @@ -12,8 +12,13 @@ java-web targets headless Java: console apps, libraries and javac. - `Deflater` writes valid DEFLATE streams using stored blocks. Compression levels and strategies currently do not reduce output size; compressed bytes and compression ratios differ from HotSpot's zlib output. +- Child processes (`ProcessBuilder`, `Runtime.exec`, `ProcessHandle`) work + under Node and run the host's programs. Starting `java` starts another + java-web VM on the same image. A child cannot use a directory that exists only + in the VM, such as `/jdk`, as its working directory. `ProcessHandle` reads + other processes from Linux's `/proc`; elsewhere it sees only its own children. - Not supported: GUI apps (AWT and Swing windows), `ConvolveOp`, child - processes, and native libraries other than JS natives. + processes in the browser, and native libraries other than JS natives. ## Locales diff --git a/src/natives/io.ts b/src/natives/io.ts index c94ed93..9e3d3a8 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -9,6 +9,29 @@ import { closeSockFd } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; import type { Natives } from "./util.ts"; +export interface ProcessPipe { + read(dst: Uint8Array, off: number, len: number): Promise; + write(bytes: Uint8Array): Promise; + available(): number; + close(): void; +} + +function pipes(t: JThread): Map { + let map = t.vm.state.get("processPipes") as Map | undefined; + if (!map) t.vm.state.set("processPipes", (map = new Map())); + return map; +} + +export function addProcessPipe(t: JThread, pipe: ProcessPipe): number { + const fd = newFd(t); + pipes(t).set(fd, pipe); + return fd; +} + +export function processPipe(t: JThread, fd: number): ProcessPipe | undefined { + return (t.vm.state.get("processPipes") as Map | undefined)?.get(fd); +} + // A file's bytes, shared by every descriptor open on it, as an inode is. interface Contents { data: Uint8Array; @@ -20,6 +43,7 @@ export class OpenFile { // "" once the file is unlinked: it lives until closed, but no path reaches it path: string; pos = 0; + append = false; writable: boolean; contents: Contents; ino?: number; @@ -181,7 +205,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo } const f = new OpenFile(abs, data, write, shared); if (truncate && st) f.setLength(0); - if (append) f.pos = f.size; + if (append) { f.pos = f.size; f.append = true; } const tab = table(t); const fd = tab.next++; tab.files.set(fd, f); @@ -246,6 +270,12 @@ export function newFd(t: JThread): number { export function closeFd(t: JThread, fd: number) { if (closeSockFd(t.vm.state, t.vm.net, fd)) return; + const pipe = processPipe(t, fd); + if (pipe) { + pipes(t).delete(fd); + pipe.close(); + return; + } const tab = table(t); const f = tab.files.get(fd); if (f) { @@ -310,9 +340,19 @@ function setFd(t: JThread, stream: JObject, owner: string, fd: number) { t.vm.setField(fdObj, "java/io/FileDescriptor", "fd", fd); } +function* readProcessPipe(t: JThread, pipe: ProcessPipe, dst: Uint8Array, off: number, len: number): NativeGen { + return yield t.block(pipe.read(dst, off, len)); +} + +function* writeProcessPipe(t: JThread, pipe: ProcessPipe, bytes: Uint8Array): NativeGen { + const error: string | null = yield t.block(pipe.write(bytes)); + if (error) throw new PendingThrow("java/io/IOException", error); +} + function writeOut(t: JThread, fd: number, bytes: Uint8Array) { if (fd === 1) t.vm.opts.stdout(bytes.slice()); else if (fd === 2) t.vm.opts.stderr(bytes.slice()); + else if (processPipe(t, fd)) return writeProcessPipe(t, processPipe(t, fd)!, bytes); else { const f = fileOf(t, fd); if (!f.writable) throw new PendingThrow("java/io/IOException", "Bad file descriptor"); @@ -448,7 +488,7 @@ export const natives: Natives = { [`${FOS}.writeBytes([BIIZ)V`]: (t, [self, b, off, len]) => { const a = nonNull(b); if (off < 0 || len < 0 || off + len > a.a.length) throw new PendingThrow("java/lang/IndexOutOfBoundsException"); - writeOut(t, fdOf(t, self, FOS), u8(a, off, len)); + return writeOut(t, fdOf(t, self, FOS), u8(a, off, len)); }, [`${FOS}.write(IZ)V`]: (t, [self, b]) => writeOut(t, fdOf(t, self, FOS), Uint8Array.of(b & 0xff)), @@ -459,12 +499,15 @@ export const natives: Natives = { if (len === 0) return 0; const fd = fdOf(t, self, FIS); if (fd === 0) return readStdin(t, u8(a), off, len); + const pipe = processPipe(t, fd); + if (pipe) return readProcessPipe(t, pipe, u8(a), off, len); return fileOf(t, fd).read(u8(a), off, len); }, [`${FIS}.read0()I`]: function* (t, [self]): NativeGen { const fd = fdOf(t, self, FIS); const one = new Uint8Array(1); - const n = fd === 0 ? yield* readStdin(t, one, 0, 1) : fileOf(t, fd).read(one, 0, 1); + const pipe = processPipe(t, fd); + const n = fd === 0 ? yield* readStdin(t, one, 0, 1) : pipe ? yield t.block(pipe.read(one, 0, 1)) : fileOf(t, fd).read(one, 0, 1); return n <= 0 ? -1 : one[0]; }, [`${FIS}.length0()J`]: (t, [self]) => BigInt(fileOf(t, fdOf(t, self, FIS)).size), @@ -479,10 +522,12 @@ export const natives: Natives = { [`${FIS}.available0()I`]: (t, [self]) => { const fd = fdOf(t, self, FIS); if (fd === 0) return (t.vm.state.get("stdin") as Stdin | undefined)?.buf.length ?? 0; + const pipe = processPipe(t, fd); + if (pipe) return pipe.available(); const f = fileOf(t, fd); return Math.min(Math.max(0, f.size - f.pos), 0x7fffffff); }, - [`${FIS}.isRegularFile0(Ljava/io/FileDescriptor;)Z`]: (t, [, fdObj]) => t.vm.getField(fdObj, "java/io/FileDescriptor", "fd") > 2, + [`${FIS}.isRegularFile0(Ljava/io/FileDescriptor;)Z`]: (t, [, fdObj]) => t.vm.getField(fdObj, "java/io/FileDescriptor", "fd") > 2 && !processPipe(t, t.vm.getField(fdObj, "java/io/FileDescriptor", "fd")), [`${RAF}.open0(Ljava/lang/String;I)V`]: (t, [self, path, mode]) => setFd(t, self, RAF, openFile(t, t.vm.jsString(path), (mode & 2) !== 0, false, false)), diff --git a/src/node.ts b/src/node.ts index 5cb4448..1666ca0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -16,6 +16,7 @@ import type { SnapshotImage } from "./snapshot.ts"; import { hostNatives } from "./hostnatives.ts"; import { ImageBackend, imageFd } from "./image.ts"; import { NodeNet } from "./net-node.ts"; +import { processNatives } from "./process-node.ts"; import { PendingThrow } from "./thread.ts"; import { setMaxHeap } from "./runtime.ts"; import type { NetBackend } from "./sockets.ts"; @@ -351,7 +352,7 @@ function nodeVm(o: NodeRunOptions): JVM { 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; + const jdk = path.resolve(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/`); } @@ -397,6 +398,7 @@ function nodeVm(o: NodeRunOptions): JVM { process.stdin.resume(); }), }); + vm.registerNatives(processNatives(fileURLToPath(import.meta.url), jdk, packaged, o.input === undefined)); bridges.set(vm, bridge); // A runaway must not take the machine down; per-site caps catch most. const maxRss = (o.maxRssMb ?? 2048) * 2 ** 20; @@ -476,6 +478,8 @@ function heapSize(s: string): number { export function main(argv: string[]) { const o: NodeRunOptions = { classpath: ["."], props: {} }; let i = 0; + const javaChild = argv[0] === "--java-child"; + if (javaChild) i++; for (; i < argv.length; i++) { const a = argv[i]; if (a === "-cp" || a === "-classpath" || a === "--class-path") o.classpath = argv[++i].split(":"); @@ -498,10 +502,14 @@ export function main(argv: string[]) { } 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 if (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue; + else if (javaChild && a.startsWith("-")) { + console.error(`Unrecognized option: ${a}\nError: Could not create the Java Virtual Machine.\nError: A fatal exception has occurred. Program will exit.`); + process.exit(1); + } + else if (a === "--max-rss") o.maxRssMb = Number(argv[++i]); else { o.mainClass = a; i++; break; } } o.args = argv.slice(i); diff --git a/src/process-node.ts b/src/process-node.ts new file mode 100644 index 0000000..1cac603 --- /dev/null +++ b/src/process-node.ts @@ -0,0 +1,363 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createConnection, createServer, type Socket } from "node:net"; +import type { Readable, Writable } from "node:stream"; +import { JArray } from "./runtime.ts"; +import { PendingThrow } from "./thread.ts"; +import type { NativeGen } from "./thread.ts"; +import { addProcessPipe, closeFd, fileOf, flushOrThrow, processPipe } from "./natives/io.ts"; +import type { OpenFile, ProcessPipe } from "./natives/io.ts"; +import type { Natives } from "./natives/util.ts"; +import { u8 } from "./natives/util.ts"; + +const drained = (output: Writable) => output.writableNeedDrain + ? new Promise((resolve) => { output.once("drain", resolve); output.once("close", resolve); }) + : undefined; + +class Pipe implements ProcessPipe { + private chunks: Uint8Array[] = []; + private length = 0; + private ended = false; + private inputs: Readable[] = []; + private pendingInputs = 0; + private output: Writable | null; + private forwarding = false; + private wake: (() => void)[] = []; + constructor(input: Readable | null, output: Writable | null) { + this.output = output; + if (input) this.attach(input); + output?.on("error", () => {}); + } + attach(input: Readable) { + this.inputs.push(input); + this.pendingInputs++; + input.on("data", (chunk: Buffer) => { + this.chunks.push(new Uint8Array(chunk)); + this.length += chunk.length; + if (this.length >= 65536) for (const stream of this.inputs) stream.pause(); + this.notify(); + }); + let done = false; + const end = () => { + if (done) return; + done = true; + if (--this.pendingInputs === 0) this.ended = true; + this.notify(); + }; + input.once("end", end); + input.once("error", end); + } + private notify() { for (const wake of this.wake.splice(0)) wake(); } + async read(dst: Uint8Array, off: number, len: number): Promise { + while (!this.length && !this.ended) await new Promise((resolve) => this.wake.push(resolve)); + if (!this.length) return -1; + const n = Math.min(len, this.length); + let copied = 0; + while (copied < n) { + const chunk = this.chunks[0]; + const take = Math.min(n - copied, chunk.length); + dst.set(chunk.subarray(0, take), off + copied); + if (take === chunk.length) this.chunks.shift(); + else this.chunks[0] = chunk.subarray(take); + copied += take; + } + this.length -= n; + if (this.length < 32768) for (const stream of this.inputs) stream.resume(); + return n; + } + write(bytes: Uint8Array): Promise { + if (!this.output || this.output.destroyed) return Promise.resolve("Stream closed"); + return new Promise((resolve) => this.output!.write(bytes.slice(), (error) => resolve(error ? error.message : null))); + } + available() { + if (!this.length) for (const input of this.inputs) input.read(); + return this.length; + } + forward(output: Writable) { + this.forwarding = true; + output.on("error", () => {}); + void (async () => { + const buffer = new Uint8Array(65536); + try { + while (!output.destroyed) { + const n = await this.read(buffer, 0, buffer.length); + if (n < 0) break; + const error = await new Promise((resolve) => output.write(buffer.slice(0, n), resolve)); + if (error) break; + await drained(output); + } + output.end(); + } finally { + this.forwarding = false; + this.close(); + } + })(); + } + close() { + if (this.forwarding) return; + for (const input of this.inputs) input.destroy(); + this.output?.end(); + this.ended = true; + this.notify(); + } +} + +// Both child descriptors must share one stream to preserve write order. +function mergedPipe(): Promise<{ reader: Socket; writer: Socket }> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "java-web-process-")); + const name = path.join(dir, "pipe"); + const server = createServer(); + let writer: Socket | undefined; + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + try { server.close(); } catch { /* not listening */ } + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* already removed */ } + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + writer?.destroy(); + cleanup(); + reject(error); + }; + server.once("error", fail); + server.once("connection", (reader) => { + if (settled) { reader.destroy(); return; } + settled = true; + cleanup(); + writer!.off("error", fail); + writer!.on("error", () => {}); + reader.on("error", () => {}); + resolve({ reader, writer: writer! }); + }); + try { + server.listen(name, () => { + writer = createConnection(name); + writer.once("error", fail); + }); + } catch (error) { fail(error as Error); } + }); +} + +type Child = { process: ChildProcess; start: bigint; exited: Promise; code: number | null; command: string; args: string[]; java: boolean }; +const decode = (a: JArray | null) => a ? new TextDecoder().decode(u8(a)).replace(/\0$/, "") : ""; +const block = (a: JArray | null) => a && a.a.length ? decode(a).split("\0") : []; + +export function processNatives(entry: string, jdk: string, packaged: boolean, hostInput: boolean): Natives { + const children = new Map(); + let boot: bigint | null = null; + try { boot = BigInt(/^btime (\d+)$/m.exec(fs.readFileSync("/proc/stat", "utf8"))![1]) * 1000n; } catch { /* no procfs */ } + const processInfo = (pid: bigint | number) => { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + if (fields[0] === "Z" || boot === null) return null; + // Linux x64 and arm64 report process start times in 1/100-second ticks. + return { parent: BigInt(fields[1]), start: boot + BigInt(fields[19]) * 10n, + cpu: (BigInt(fields[11]) + BigInt(fields[12])) * 10000000n }; + } catch { return null; } + }; + return { + "java/lang/ProcessImpl.forkAndExec(I[B[B[BI[BI[B[IZ)I": function* (t, [, , , prog, argBlock, , envBlock, , dir, fds, redirectError]): NativeGen { + let command = decode(prog); + let args = block(argBlock); + const testJdk = t.vm.props.get("test.jdk"); + const java = command === "java" || command === "/jdk/bin/java" || command === `${jdk}/bin/java` || !!(testJdk && command === `${testJdk}/bin/java`); + if (java) { + command = process.execPath; + args = [entry, "--java-child", ...(packaged ? [] : ["--jdk", jdk]), ...args]; + } + const env = envBlock === null ? t.vm.opts.env : Object.fromEntries(block(envBlock).map((s) => { + const i = s.indexOf("="); + return [s.slice(0, i), s.slice(i + 1)]; + })); + const descriptors = (fds as JArray).a as Int32Array; + const opened: number[] = []; + const stdio: (number | "pipe" | Socket)[] = []; + let merged: { reader: Socket; writer: Socket } | undefined; + let pipelineInput: Pipe | undefined; + let imageInput: OpenFile | undefined; + try { + for (let i = 0; i < 3; i++) { + const fd = descriptors[i]; + if (i === 0 && fd === 0 && hostInput) stdio.push(0); + else if (fd < 0 || fd <= 2) stdio.push("pipe"); + else if (i === 0 && processPipe(t, fd) instanceof Pipe) { + pipelineInput = processPipe(t, fd) as Pipe; + stdio.push("pipe"); + } else { + const file = fileOf(t, fd); + flushOrThrow(t, file); + if (i === 0 && file.path.startsWith(`${t.vm.opts.javaHome}/`)) { + imageInput = file; + stdio.push("pipe"); + } else { + const hostFd = fs.openSync(file.path, i === 0 ? fs.constants.O_RDONLY : fs.constants.O_WRONLY | (file.append ? fs.constants.O_APPEND : 0)); + opened.push(hostFd); + stdio.push(hostFd); + } + } + } + if (redirectError && stdio[1] === "pipe") { + const result: { reader: Socket; writer: Socket } | Error = yield t.block(mergedPipe().catch((error: Error) => error)); + if (result instanceof Error) throw result; + merged = result; + stdio[1] = stdio[2] = merged.writer; + } else if (redirectError) stdio[2] = stdio[1]; + const child = spawn(command, args, { cwd: dir ? decode(dir) : t.vm.opts.cwd, env, stdio: stdio as [any, any, any] }); + merged?.writer.destroy(); + const status = (code: number | null, signal: NodeJS.Signals | null) => + code ?? 128 + ((os.constants.signals as Record)[signal ?? ""] ?? 0); + const exited = new Promise((resolve) => child.once("exit", (code, signal) => resolve(status(code, signal)))); + const created: number[] = []; + for (let i = 0; i < 3; i++) { + if (i === 2 && redirectError) { + descriptors[i] = -1; + } else if (descriptors[i] < 0) { + const pipe = new Pipe(i === 0 ? null : i === 1 && merged ? merged.reader : child.stdio[i] as Readable, i === 0 ? child.stdio[i] as Writable : null); + descriptors[i] = addProcessPipe(t, pipe); + created.push(descriptors[i]); + } else { + if (descriptors[i] <= 2 && i !== 0) { + const output = descriptors[i] === 1 ? t.vm.opts.stdout : t.vm.opts.stderr; + const stream = i === 1 && merged ? merged.reader : child.stdio[i] as Readable; + stream?.on("error", () => {}); + stream?.on("data", (data: Buffer) => { try { output(new Uint8Array(data)); } catch { /* output closed */ } }); + } + if (i === 0 && descriptors[i] === 0 && stdio[i] === "pipe") { + const input = child.stdio[i] as Writable; + input.on("error", () => {}); + void (async () => { + while (!input.destroyed) { + const chunk = await t.vm.opts.stdin?.(); + if (!chunk) break; + if (!input.write(chunk)) await new Promise((resolve) => input.once("drain", resolve)); + } + input.end(); + })(); + } + descriptors[i] = -1; + } + } + const error: NodeJS.ErrnoException | null = yield t.block(new Promise((resolve) => { + child.once("spawn", () => resolve(null)); + child.once("error", resolve); + })); + if (error) { + for (const fd of created) closeFd(t, fd); + const errno = Math.abs(error.errno ?? 5); + const reason = error.code === "ENOENT" ? "No such file or directory" : error.code === "EACCES" ? "Permission denied" : error.message; + throw new PendingThrow("java/io/IOException", `error=${errno}, ${reason}`); + } + const pid = child.pid!; + const record: Child = { process: child, start: processInfo(pid)?.start ?? BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), java, exited: null! }; + record.exited = exited.then((code) => { + record.code = code; + // lets inherited output that came in with the exit reach the VM first; + // waiting for the streams to close would hang while a grandchild holds them + return new Promise((resolve) => setImmediate(() => resolve(code))); + }); + children.set(pid, record); + if (pipelineInput) pipelineInput.forward(child.stdin!); + if (imageInput) { + const input = imageInput, output = child.stdin!; + output.on("error", () => {}); + void (async () => { + const buffer = new Uint8Array(65536); + try { + while (!output.destroyed) { + const n = input.read(buffer, 0, buffer.length); + if (n < 0) break; + const error = await new Promise((resolve) => output.write(buffer.slice(0, n), resolve)); + if (error) break; + await drained(output); + } + } finally { output.end(); } + })().catch(() => {}); + } + return pid; + } catch (e) { + merged?.writer.destroy(); + merged?.reader.destroy(); + if (e instanceof PendingThrow) throw e; + const error = e as NodeJS.ErrnoException; + throw new PendingThrow("java/io/IOException", `error=${Math.abs(error.errno ?? 5)}, ${error.message}`); + } finally { + for (const fd of opened) fs.closeSync(fd); + } + }, + "java/lang/ProcessHandleImpl.getCurrentPid0()J": () => BigInt(process.pid), + "java/lang/ProcessHandleImpl.isAlive0(J)J": (_t, [pid]) => { + const child = children.get(Number(pid)); + if (child) return child.code === null ? child.start : -1n; + if (Number(pid) === process.pid && boot === null) return 0n; + return pid > 0n ? processInfo(pid)?.start ?? -1n : -1n; + }, + "java/lang/ProcessHandleImpl.parent0(JJ)J": (_t, [pid, start]) => { + const child = children.get(Number(pid)); + if (child) return child.code === null && (start === 0n || start === child.start) ? BigInt(process.pid) : -1n; + const info = pid > 0n ? processInfo(pid) : null; + return info && (start === 0n || start === info.start) ? info.parent : -1n; + }, + "java/lang/ProcessHandleImpl.waitForProcessExit0(JZ)I": function* (t, [pid, reap]): NativeGen { + const child = children.get(Number(pid)); + if (!child) return -2; + const code = yield t.block(child.exited); + if (reap) children.delete(Number(pid)); + return code; + }, + "java/lang/ProcessHandleImpl.destroy0(JJZ)Z": (_t, [pid, start, force]) => { + if (pid <= 0n || pid === BigInt(process.pid)) return 0; + const child = children.get(Number(pid)); + if (child) return child.code === null && (start === 0n || start === child.start) && child.process.kill(force ? "SIGKILL" : "SIGTERM") ? 1 : 0; + const info = processInfo(pid); + if (!info || (start !== 0n && start !== info.start)) return 0; + try { process.kill(Number(pid), force ? "SIGKILL" : "SIGTERM"); return 1; } catch { return 0; } + }, + "java/lang/ProcessHandleImpl.getProcessPids0(J[J[J[J)I": (_t, [pid, pids, ppids, starts]) => { + if (boot === null) { + const live = [...children.entries()].filter(([, child]) => child.code === null && (pid === 0n || pid === BigInt(process.pid))); + for (let i = 0; i < Math.min(live.length, pids.a.length); i++) { + pids.a[i] = BigInt(live[i][0]); + if (ppids) ppids.a[i] = BigInt(process.pid); + if (starts) starts.a[i] = live[i][1].start; + } + return live.length; + } + let count = 0; + let names: string[]; + try { names = fs.readdirSync("/proc"); } catch { names = []; } + for (const name of names) { + if (!/^\d+$/.test(name)) continue; + const info = processInfo(Number(name)); + if (!info || (pid !== 0n && info.parent !== pid)) continue; + if (count < pids.a.length) { + pids.a[count] = BigInt(name); + if (ppids) ppids.a[count] = info.parent; + if (starts) starts.a[count] = info.start; + } + count++; + } + return count; + }, + "java/lang/ProcessHandleImpl$Info.initIDs()V": () => undefined, + "java/lang/ProcessHandleImpl$Info.info0(J)V": (t, [self, pid]) => { + const child = children.get(Number(pid)); + const info = processInfo(pid); + if (!info || (child && child.code !== null)) return; + try { + const command = child?.java ? (child.command === "java" ? "/jdk/bin/java" : child.command) : fs.readlinkSync(`/proc/${pid}/exe`); + const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0$/, "").split("\0").slice(1); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "startTime", info.start); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "totalTime", info.cpu); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "command", t.vm.str(command)); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "arguments", t.vm.newObjectArray("java/lang/String", (child?.args ?? argv).map((arg) => t.vm.str(arg)))); + if (fs.statSync(`/proc/${pid}`).uid === process.getuid?.()) + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "user", t.vm.str(os.userInfo().username)); + } catch { /* process exited or is inaccessible */ } + }, + }; +} diff --git a/test/image.test.ts b/test/image.test.ts index 0a5b1c6..c4e60ec 100644 --- a/test/image.test.ts +++ b/test/image.test.ts @@ -131,6 +131,15 @@ test("dist/node.js and the java-web command run a program as HotSpot does", { sk } }); +test("dist/node.js starts child processes", { skip: skip || process.platform !== "linux" }, () => { + mkdirSync(out, { recursive: true }); + spawnSync(hotspot("javac"), ["-d", out, join(root, "test", "java", "Processes.java")], { stdio: "inherit" }); + const ref = spawnSync(hotspot("java"), ["-cp", out, "Processes"], { encoding: "utf8" }); + const got = spawnSync(process.execPath, [node, "-cp", out, "Processes"], { encoding: "utf8", timeout: 120_000 }); + assert.equal(got.error, undefined); + assert.deepEqual({ stdout: got.stdout, stderr: got.stderr, code: got.status }, { stdout: ref.stdout, stderr: ref.stderr, code: ref.status }); +}); + test("dist/node.js exports runNode, and javac runs on the packaged image", { skip }, async () => { mkdirSync(out, { recursive: true }); spawnSync(hotspot("javac"), ["-d", out, join(root, "test", "java", "Javac.java")], { stdio: "inherit" }); diff --git a/test/java/Processes.java b/test/java/Processes.java new file mode 100644 index 0000000..2782eb2 --- /dev/null +++ b/test/java/Processes.java @@ -0,0 +1,103 @@ +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.concurrent.TimeUnit; + +public class Processes { + static String output(Process p) throws Exception { + return new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + } + public static void main(String[] args) throws Exception { + if (args.length > 0) { + if (args[0].equals("wait")) { Thread.sleep(5000); return; } + System.out.println("nested=" + System.getProperty("process.test")); + return; + } + Process echo = new ProcessBuilder("echo", "hello").start(); + System.out.println("echo=" + output(echo) + ":" + echo.waitFor()); + Process cat = new ProcessBuilder("cat").start(); + cat.getOutputStream().write("pipe text\n".getBytes(StandardCharsets.UTF_8)); + cat.getOutputStream().close(); + System.out.println("cat=" + output(cat) + ":" + cat.waitFor()); + var pipeline = ProcessBuilder.startPipeline(java.util.List.of( + new ProcessBuilder("echo", "pipeline"), new ProcessBuilder("cat"))); + System.out.println("pipeline=" + output(pipeline.get(1)) + ":" + pipeline.get(0).waitFor() + ":" + pipeline.get(1).waitFor()); + Process nested = new ProcessBuilder(System.getProperty("java.home") + "/bin/java", "-Dprocess.test=ok", "-cp", System.getProperty("java.class.path"), "Processes", "child").start(); + System.out.println(output(nested) + ":" + nested.waitFor()); + Process nestedInfo = new ProcessBuilder(System.getProperty("java.home") + "/bin/java", "-cp", System.getProperty("java.class.path"), "Processes", "wait").start(); + System.out.println("java-info=" + nestedInfo.info().command().map(s -> s.endsWith("/bin/java")).orElse(false)); + nestedInfo.destroyForcibly(); + nestedInfo.waitFor(); + try { + new ProcessBuilder("/no-such-java-web-program").start(); + System.out.println("missing=false"); + } catch (IOException e) { + System.out.println("missing=true"); + } + Process background = new ProcessBuilder("sh", "-c", "sleep 2 & exit 0").start(); + System.out.println("background=" + background.waitFor(500, TimeUnit.MILLISECONDS)); + Thread.sleep(2200); + Process release = new ProcessBuilder("cat").redirectInput(new File(System.getProperty("java.home"), "release")).start(); + System.out.println("jdk-input=" + output(release).contains("JAVA_VERSION=") + ":" + release.waitFor()); + Process tree = new ProcessBuilder("sh", "-c", "sleep 5 & wait").start(); + try { + ProcessHandle descendant = null; + for (int i = 0; i < 100 && descendant == null; i++) { + descendant = tree.toHandle().children().findFirst().orElse(null); + if (descendant == null) Thread.sleep(20); + } + System.out.println("descendant=" + (descendant != null && descendant.destroyForcibly())); + } finally { + if (!tree.waitFor(6, TimeUnit.SECONDS)) { + tree.toHandle().descendants().forEach(ProcessHandle::destroyForcibly); + tree.destroyForcibly(); + tree.waitFor(); + } + } + for (int i = 0; i < 20; i++) { + new ProcessBuilder("sh", "-c", "echo inherited " + i + "; echo inherited-err " + i + " >&2").inheritIO().start().waitFor(); + System.out.println("after " + i); + System.err.println("after-err " + i); + } + Process exit = new ProcessBuilder("sh", "-c", "exit 17").start(); + System.out.println("exit=" + exit.waitFor()); + Path dir = Files.createTempDirectory("process-test"); + try { + ProcessBuilder pb = new ProcessBuilder("sh", "-c", "printf '%s:%s' \"$PWD\" \"$PROC_TEST\""); + pb.directory(dir.toFile()); + pb.environment().put("PROC_TEST", "value"); + Process env = pb.start(); + System.out.println("env=" + output(env).replace(dir.toString(), "dir") + ":" + env.waitFor()); + Path file = dir.resolve("out"); + Files.writeString(file, "input\n"); + Process redirectedInput = new ProcessBuilder("cat").redirectInput(file.toFile()).start(); + System.out.println("input=" + output(redirectedInput) + ":" + redirectedInput.waitFor()); + Process merged = new ProcessBuilder("sh", "-c", "printf out; printf err >&2").redirectError(file.toFile()).redirectErrorStream(true).start(); + String both = output(merged); + System.out.println("merged=" + (both.contains("out") && both.contains("err")) + ":" + merged.waitFor()); + Process ordered = new ProcessBuilder("sh", "-c", "i=0; while [ $i -lt 100 ]; do printf a; printf b >&2; i=$((i+1)); done").redirectErrorStream(true).start(); + System.out.println("order=" + output(ordered).equals("ab".repeat(100)) + ":" + ordered.waitFor()); + Process redirect = new ProcessBuilder("echo", "file").redirectOutput(file.toFile()).start(); + System.out.println("redirect=" + redirect.waitFor() + ":" + Files.readString(file).trim()); + Process append = new ProcessBuilder("echo", "extra").redirectOutput(ProcessBuilder.Redirect.appendTo(file.toFile())).start(); + System.out.println("append=" + append.waitFor() + ":" + Files.readString(file).trim().replace("\n", "|")); + Process sleep = new ProcessBuilder("sleep", "2").start(); + System.out.println("info=" + sleep.info().command().isPresent() + ":" + sleep.info().startInstant().isPresent() + ":" + sleep.info().totalCpuDuration().isPresent() + ":" + ProcessHandle.current().info().command().isPresent()); + System.out.println("handle=" + sleep.toHandle().isAlive() + ":" + (sleep.pid() == sleep.toHandle().pid())); + System.out.println("child=" + ProcessHandle.current().children().anyMatch(p -> p.pid() == sleep.pid())); + System.out.println("all=" + ProcessHandle.allProcesses().anyMatch(p -> p.pid() == ProcessHandle.current().pid())); + System.out.println("parent=" + sleep.toHandle().parent().map(p -> p.pid() == ProcessHandle.current().pid()).orElse(false)); + System.out.println("timeout=" + sleep.waitFor(50, TimeUnit.MILLISECONDS)); + sleep.destroyForcibly(); + sleep.waitFor(); + System.out.println("alive=" + sleep.isAlive()); + Process terminated = new ProcessBuilder("sleep", "2").start(); + terminated.destroy(); + terminated.waitFor(); + System.out.println("destroy=" + terminated.isAlive()); + } finally { + Files.deleteIfExists(dir.resolve("out")); + Files.delete(dir); + } + } +} diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index c31b226..90345f0 100644 --- a/test/jtreg/passing.txt +++ b/test/jtreg/passing.txt @@ -849,13 +849,25 @@ java/lang/Object/WaitTooLong.java java/lang/Package/GetPackages.java java/lang/Package/PackageVersionTest.java java/lang/PrimitiveSumMinMaxTest.java +java/lang/Process/WaitFor.java +java/lang/Process/WaitForDuration.java +java/lang/ProcessBuilder/DestroyTest.java java/lang/ProcessBuilder/InheritIOEHandle.java +java/lang/ProcessBuilder/PipelineTest.java +java/lang/ProcessBuilder/ProcessReaperCCL.java +java/lang/ProcessBuilder/Zombies.java +java/lang/ProcessHandle/Basic.java java/lang/RuntimePermission/ExitVM.java java/lang/RuntimeTests/Resources.java java/lang/RuntimeTests/Version/Basic.java java/lang/RuntimeTests/Version/VersionProps.java java/lang/RuntimeTests/exec/BadEnvp.java +java/lang/RuntimeTests/exec/ConcurrentRead.java java/lang/RuntimeTests/exec/ExecEmptyString.java +java/lang/RuntimeTests/exec/ExecWithInput.java +java/lang/RuntimeTests/exec/LotsOfDestroys.java +java/lang/RuntimeTests/exec/LotsOfOutput.java +java/lang/RuntimeTests/exec/SleepyCat.java java/lang/RuntimeTests/exec/WinCommand.java java/lang/RuntimeTests/loadLibrary/LoadLibraryTest.java java/lang/ScopedValue/ManyBindings.java @@ -873,6 +885,7 @@ java/lang/StackTraceElement/ModuleFrames.java java/lang/StackTraceElement/PublicConstructor.java java/lang/StackTraceElement/SerialTest.java java/lang/StackWalker/AcrossThreads.java +java/lang/StackWalker/CallerFromMain.java java/lang/StackWalker/DumpStackTest.java java/lang/StackWalker/EmbeddedStackWalkTest.java java/lang/StackWalker/GetCallerClassTest.java diff --git a/test/programs.test.ts b/test/programs.test.ts index db15cef..fba1488 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -27,7 +27,8 @@ for (const file of files) { const main = file.slice(0, -5); const input = readInput(main); const vmFlags = flags(file, "java"); - test(main, async () => { + // other processes are visible to java-web through Linux's /proc only + test(main, { skip: main === "Processes" && process.platform !== "linux" }, async () => { const ref = spawnSync(hotspot("java"), [...vmFlags, "-cp", out, main], { input, encoding: "utf8" }); const stdout: Uint8Array[] = []; const stderr: Uint8Array[] = [];