From 998016166f9ba0f85b8b617f4ecbff9befa3d840 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:37:07 -0600 Subject: [PATCH 01/20] Add preliminary Node child process support. --- src/natives/io.ts | 50 +++++++++-- src/node.ts | 12 ++- src/process-node.ts | 190 +++++++++++++++++++++++++++++++++++++++ test/java/Processes.java | 46 ++++++++++ 4 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 src/process-node.ts create mode 100644 test/java/Processes.java diff --git a/src/natives/io.ts b/src/natives/io.ts index ce6571c..cf91bc6 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 pipes(t).get(fd); +} + // A file's bytes, shared by every descriptor open on it, as an inode is. interface Contents { data: Uint8Array; @@ -246,6 +269,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 +339,15 @@ function setFd(t: JThread, stream: JObject, owner: string, fd: number) { t.vm.setField(fdObj, "java/io/FileDescriptor", "fd", fd); } +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,23 +483,26 @@ 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)), [`${FIS}.open0(Ljava/lang/String;)V`]: (t, [self, path]) => setFd(t, self, FIS, openFile(t, t.vm.jsString(path), false, false, false)), - [`${FIS}.readBytes([BII)I`]: (t, [self, b, off, len]) => { + [`${FIS}.readBytes([BII)I`]: function* (t, [self, b, off, len]): NativeGen { const a = nonNull(b); if (off < 0 || len < 0 || off + len > a.a.length) throw new PendingThrow("java/lang/IndexOutOfBoundsException"); if (len === 0) return 0; const fd = fdOf(t, self, FIS); - if (fd === 0) return readStdin(t, u8(a), off, len); + if (fd === 0) return yield* readStdin(t, u8(a), off, len); + const pipe = processPipe(t, fd); + if (pipe) return yield t.block(pipe.read(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 +517,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 3c5259e..777dca9 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"; @@ -389,6 +390,7 @@ function nodeVm(o: NodeRunOptions): JVM { process.stdin.resume(); }), }); + vm.registerNatives(processNatives(fileURLToPath(import.meta.url), jdk, packaged)); bridges.set(vm, bridge); // A runaway must not take the machine down; per-site caps catch most. const maxRss = (o.maxRssMb ?? 2048) * 2 ** 20; @@ -468,6 +470,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(":"); @@ -490,10 +494,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 (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 (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue; + 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..682fa26 --- /dev/null +++ b/src/process-node.ts @@ -0,0 +1,190 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +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, fileOf, flushOrThrow } from "./natives/io.ts"; +import type { ProcessPipe } from "./natives/io.ts"; +import type { Natives } from "./natives/util.ts"; +import { u8 } from "./natives/util.ts"; + +class Pipe implements ProcessPipe { + private chunks: Uint8Array[] = []; + private length = 0; + private ended = false; + private inputs: Readable[] = []; + private pendingInputs = 0; + private wake: (() => void)[] = []; + constructor(input: Readable | null, private output: Writable | null) { + 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; + } + close() { + for (const input of this.inputs) input.destroy(); + this.output?.end(); + this.ended = true; + this.notify(); + } +} + +type Child = { process: ChildProcess; start: bigint; exited: Promise; code: number | null; command: string; args: string[] }; +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): Natives { + const children = new Map(); + 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"); + if (command === "java" || command === "/jdk/bin/java" || command === `${jdk}/bin/java` || (testJdk && command === `${testJdk}/bin/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")[] = []; + try { + for (let i = 0; i < 3; i++) { + const fd = descriptors[i]; + if (fd < 0 || fd <= 2) stdio.push("pipe"); + else { + const file = fileOf(t, fd); + flushOrThrow(t, file); + const hostFd = fs.openSync(file.path, i === 0 ? fs.constants.O_RDONLY : fs.constants.O_WRONLY | (file.pos > 0 ? fs.constants.O_APPEND : 0)); + opened.push(hostFd); + stdio.push(hostFd); + } + } + if (redirectError && typeof stdio[1] === "number") stdio[2] = stdio[1]; + const child = spawn(command, args, { cwd: dir ? decode(dir) : t.vm.opts.cwd, env, stdio: stdio as [any, any, any] }); + const exited = new Promise((resolve) => child.once("exit", (code, signal) => { + resolve(code ?? 128 + ((os.constants.signals as Record)[signal ?? ""] ?? 0)); + })); + const error: NodeJS.ErrnoException | null = yield t.block(new Promise((resolve) => { + child.once("spawn", () => resolve(null)); + child.once("error", resolve); + })); + if (error) { + 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: BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; + record.exited = exited.then((code) => (record.code = code)); + children.set(pid, record); + let stdoutPipe: Pipe | undefined; + for (let i = 0; i < 3; i++) { + if (i === 2 && redirectError) { + if (stdoutPipe) stdoutPipe.attach(child.stdio[2] as Readable); + else if (stdio[2] === "pipe") (child.stdio[2] as Readable).on("data", (data: Buffer) => t.vm.opts.stdout(new Uint8Array(data))); + descriptors[i] = -1; + } else if (descriptors[i] < 0) { + const pipe = new Pipe(i === 0 ? null : child.stdio[i] as Readable, i === 0 ? child.stdio[i] as Writable : null); + if (i === 1) stdoutPipe = pipe; + descriptors[i] = addProcessPipe(t, pipe); + } else { + if (descriptors[i] <= 2 && i !== 0) (child.stdio[i] as Readable)?.on("data", (data: Buffer) => (i === 1 ? t.vm.opts.stdout : t.vm.opts.stderr)(new Uint8Array(data))); + if (descriptors[i] === 0) (child.stdio[i] as Writable)?.end(); + descriptors[i] = -1; + } + } + return pid; + } catch (e) { + 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]) => { + if (Number(pid) === process.pid) return 0n; + const child = children.get(Number(pid)); + return child && child.code === null ? child.start : -1n; + }, + "java/lang/ProcessHandleImpl.parent0(JJ)J": (_t, [pid]) => children.has(Number(pid)) ? BigInt(process.pid) : -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]) => { + const child = children.get(Number(pid)); + return child && child.code === null && (start === 0n || start === child.start) && child.process.kill(force ? "SIGKILL" : "SIGTERM") ? 1 : 0; + }, + "java/lang/ProcessHandleImpl.getProcessPids0(J[J[J[J)I": (_t, [pid, pids, ppids, starts]) => { + 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; + }, + "java/lang/ProcessHandleImpl$Info.initIDs()V": () => undefined, + "java/lang/ProcessHandleImpl$Info.info0(J)V": (t, [self, pid]) => { + const child = children.get(Number(pid)); + if (!child || child.code !== null) return; + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "startTime", child.start); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "command", t.vm.str(child.command)); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "arguments", t.vm.newObjectArray("java/lang/String", child.args.map((arg) => t.vm.str(arg)))); + }, + }; +} diff --git a/test/java/Processes.java b/test/java/Processes.java new file mode 100644 index 0000000..9a8943d --- /dev/null +++ b/test/java/Processes.java @@ -0,0 +1,46 @@ +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) { + 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()); + 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 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"); + Process redirect = new ProcessBuilder("echo", "file").redirectOutput(file.toFile()).start(); + System.out.println("redirect=" + redirect.waitFor() + ":" + Files.readString(file).trim()); + Process sleep = new ProcessBuilder("sleep", "2").start(); + System.out.println("handle=" + sleep.toHandle().isAlive() + ":" + (sleep.pid() == sleep.toHandle().pid())); + System.out.println("timeout=" + sleep.waitFor(50, TimeUnit.MILLISECONDS)); + sleep.destroyForcibly(); + sleep.waitFor(); + System.out.println("alive=" + sleep.isAlive()); + } finally { + Files.deleteIfExists(dir.resolve("out")); + Files.delete(dir); + } + } +} From 3978cc9320fd6efe03350e93e3e2f7ea85df8eb5 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:47:51 -0600 Subject: [PATCH 02/20] Preserve child output until streams close. --- src/node.ts | 4 ++-- src/process-node.ts | 42 ++++++++++++++++++++++++++++++---------- test/java/Processes.java | 11 +++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/node.ts b/src/node.ts index 777dca9..3e999aa 100644 --- a/src/node.ts +++ b/src/node.ts @@ -344,7 +344,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/`); } @@ -390,7 +390,7 @@ function nodeVm(o: NodeRunOptions): JVM { process.stdin.resume(); }), }); - vm.registerNatives(processNatives(fileURLToPath(import.meta.url), jdk, packaged)); + 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; diff --git a/src/process-node.ts b/src/process-node.ts index 682fa26..b6e2a35 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -16,8 +16,10 @@ class Pipe implements ProcessPipe { private ended = false; private inputs: Readable[] = []; private pendingInputs = 0; + private output: Writable | null; private wake: (() => void)[] = []; - constructor(input: Readable | null, private output: Writable | null) { + constructor(input: Readable | null, output: Writable | null) { + this.output = output; if (input) this.attach(input); output?.on("error", () => {}); } @@ -78,7 +80,7 @@ type Child = { process: ChildProcess; start: bigint; exited: Promise; co 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): Natives { +export function processNatives(entry: string, jdk: string, packaged: boolean, hostInput: boolean): Natives { const children = new Map(); return { "java/lang/ProcessImpl.forkAndExec(I[B[B[BI[BI[B[IZ)I": function* (t, [, , , prog, argBlock, , envBlock, , dir, fds, redirectError]): NativeGen { @@ -99,7 +101,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean): N try { for (let i = 0; i < 3; i++) { const fd = descriptors[i]; - if (fd < 0 || fd <= 2) stdio.push("pipe"); + if (i === 0 && fd === 0 && hostInput) stdio.push(0); + else if (fd < 0 || fd <= 2) stdio.push("pipe"); else { const file = fileOf(t, fd); flushOrThrow(t, file); @@ -110,9 +113,10 @@ export function processNatives(entry: string, jdk: string, packaged: boolean): N } if (redirectError && typeof stdio[1] === "number") stdio[2] = stdio[1]; const child = spawn(command, args, { cwd: dir ? decode(dir) : t.vm.opts.cwd, env, stdio: stdio as [any, any, any] }); - const exited = new Promise((resolve) => child.once("exit", (code, signal) => { - resolve(code ?? 128 + ((os.constants.signals as Record)[signal ?? ""] ?? 0)); - })); + 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 closed = new Promise((resolve) => child.once("close", (code, signal) => resolve(status(code, signal)))); const error: NodeJS.ErrnoException | null = yield t.block(new Promise((resolve) => { child.once("spawn", () => resolve(null)); child.once("error", resolve); @@ -124,7 +128,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean): N } const pid = child.pid!; const record: Child = { process: child, start: BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; - record.exited = exited.then((code) => (record.code = code)); + void exited.then((code) => (record.code = code)); + record.exited = closed; children.set(pid, record); let stdoutPipe: Pipe | undefined; for (let i = 0; i < 3; i++) { @@ -137,8 +142,22 @@ export function processNatives(entry: string, jdk: string, packaged: boolean): N if (i === 1) stdoutPipe = pipe; descriptors[i] = addProcessPipe(t, pipe); } else { - if (descriptors[i] <= 2 && i !== 0) (child.stdio[i] as Readable)?.on("data", (data: Buffer) => (i === 1 ? t.vm.opts.stdout : t.vm.opts.stderr)(new Uint8Array(data))); - if (descriptors[i] === 0) (child.stdio[i] as Writable)?.end(); + if (descriptors[i] <= 2 && i !== 0) { + const output = descriptors[i] === 1 ? t.vm.opts.stdout : t.vm.opts.stderr; + (child.stdio[i] as Readable)?.on("data", (data: Buffer) => output(new Uint8Array(data))); + } + 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; } } @@ -183,7 +202,10 @@ export function processNatives(entry: string, jdk: string, packaged: boolean): N const child = children.get(Number(pid)); if (!child || child.code !== null) return; t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "startTime", child.start); - t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "command", t.vm.str(child.command)); + let command = child.command; + try { command = fs.readlinkSync(`/proc/${pid}/exe`); } catch { /* process exited */ } + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "command", t.vm.str(command)); + t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "user", t.vm.str(os.userInfo().username)); t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "arguments", t.vm.newObjectArray("java/lang/String", child.args.map((arg) => t.vm.str(arg)))); }, }; diff --git a/test/java/Processes.java b/test/java/Processes.java index 9a8943d..89d6bb7 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -30,14 +30,25 @@ public static void main(String[] args) throws Exception { 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").redirectErrorStream(true).start(); + String both = output(merged); + System.out.println("merged=" + (both.contains("out") && both.contains("err")) + ":" + merged.waitFor()); Process redirect = new ProcessBuilder("echo", "file").redirectOutput(file.toFile()).start(); System.out.println("redirect=" + redirect.waitFor() + ":" + Files.readString(file).trim()); Process sleep = new ProcessBuilder("sleep", "2").start(); 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("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); From 82715e4e157cb8be67ccf8d127d7d902855c9efd Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:51:52 -0600 Subject: [PATCH 03/20] Connect child pipelines and track process exit independently. --- src/process-node.ts | 64 +++++++++++++++++++++++++++++----------- test/java/Processes.java | 13 ++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index b6e2a35..78a8571 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -5,7 +5,7 @@ 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, fileOf, flushOrThrow } from "./natives/io.ts"; +import { addProcessPipe, closeFd, fileOf, flushOrThrow, processPipe } from "./natives/io.ts"; import type { ProcessPipe } from "./natives/io.ts"; import type { Natives } from "./natives/util.ts"; import { u8 } from "./natives/util.ts"; @@ -17,6 +17,7 @@ class Pipe implements ProcessPipe { 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; @@ -68,7 +69,27 @@ class Pipe implements ProcessPipe { 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; + } + 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; @@ -98,12 +119,16 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho const descriptors = (fds as JArray).a as Int32Array; const opened: number[] = []; const stdio: (number | "pipe")[] = []; + let pipelineInput: Pipe | 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 { + 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); const hostFd = fs.openSync(file.path, i === 0 ? fs.constants.O_RDONLY : fs.constants.O_WRONLY | (file.pos > 0 ? fs.constants.O_APPEND : 0)); @@ -116,21 +141,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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 closed = new Promise((resolve) => child.once("close", (code, signal) => resolve(status(code, signal)))); - const error: NodeJS.ErrnoException | null = yield t.block(new Promise((resolve) => { - child.once("spawn", () => resolve(null)); - child.once("error", resolve); - })); - if (error) { - 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: BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; - void exited.then((code) => (record.code = code)); - record.exited = closed; - children.set(pid, record); + const created: number[] = []; let stdoutPipe: Pipe | undefined; for (let i = 0; i < 3; i++) { if (i === 2 && redirectError) { @@ -141,6 +152,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho const pipe = new Pipe(i === 0 ? null : child.stdio[i] as Readable, i === 0 ? child.stdio[i] as Writable : null); if (i === 1) stdoutPipe = pipe; 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; @@ -161,6 +173,24 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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: BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; + record.exited = exited.then((code) => { + record.code = code; + return new Promise((resolve) => setImmediate(() => resolve(code))); + }); + children.set(pid, record); + if (pipelineInput) pipelineInput.forward(child.stdin!); return pid; } catch (e) { if (e instanceof PendingThrow) throw e; diff --git a/test/java/Processes.java b/test/java/Processes.java index 89d6bb7..d603e42 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -18,8 +18,20 @@ public static void main(String[] args) throws Exception { 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()); + try { + new ProcessBuilder("/no-such-java-web-program").start(); + System.out.println("missing=false"); + } catch (IOException e) { + System.out.println("missing=" + e.getMessage().contains("error=2")); + } + Process background = new ProcessBuilder("sh", "-c", "sleep 2 & exit 0").start(); + System.out.println("background=" + background.waitFor(500, TimeUnit.MILLISECONDS)); + Thread.sleep(2200); Process exit = new ProcessBuilder("sh", "-c", "exit 17").start(); System.out.println("exit=" + exit.waitFor()); Path dir = Files.createTempDirectory("process-test"); @@ -41,6 +53,7 @@ public static void main(String[] args) throws Exception { Process sleep = new ProcessBuilder("sleep", "2").start(); 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("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(); From c5e882df1a0fcecfb15fac77daaf880cd8a2ee5a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:58:42 -0600 Subject: [PATCH 04/20] Keep ordinary file reads synchronous. --- src/natives/io.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index cf91bc6..9915f2a 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -29,7 +29,7 @@ export function addProcessPipe(t: JThread, pipe: ProcessPipe): number { } export function processPipe(t: JThread, fd: number): ProcessPipe | undefined { - return pipes(t).get(fd); + 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. @@ -339,6 +339,10 @@ 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); @@ -488,14 +492,14 @@ export const natives: Natives = { [`${FOS}.write(IZ)V`]: (t, [self, b]) => writeOut(t, fdOf(t, self, FOS), Uint8Array.of(b & 0xff)), [`${FIS}.open0(Ljava/lang/String;)V`]: (t, [self, path]) => setFd(t, self, FIS, openFile(t, t.vm.jsString(path), false, false, false)), - [`${FIS}.readBytes([BII)I`]: function* (t, [self, b, off, len]): NativeGen { + [`${FIS}.readBytes([BII)I`]: (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"); if (len === 0) return 0; const fd = fdOf(t, self, FIS); - if (fd === 0) return yield* readStdin(t, u8(a), off, len); + if (fd === 0) return readStdin(t, u8(a), off, len); const pipe = processPipe(t, fd); - if (pipe) return yield t.block(pipe.read(u8(a), off, len)); + 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 { From e310b06ede7aaec5f802b811c116edbeff874002 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:02:21 -0600 Subject: [PATCH 05/20] Look up process parents on Linux. --- src/process-node.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 78a8571..191baf9 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -204,9 +204,23 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho "java/lang/ProcessHandleImpl.isAlive0(J)J": (_t, [pid]) => { if (Number(pid) === process.pid) return 0n; const child = children.get(Number(pid)); - return child && child.code === null ? child.start : -1n; + if (child) return child.code === null ? child.start : -1n; + if (pid <= 0n) return -1n; + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ") ? -1n : 0n; + } catch { return -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; + if (pid <= 0n) return -1n; + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return fields[0] === "Z" ? -1n : BigInt(fields[1]); + } catch { return -1n; } }, - "java/lang/ProcessHandleImpl.parent0(JJ)J": (_t, [pid]) => children.has(Number(pid)) ? BigInt(process.pid) : -1n, "java/lang/ProcessHandleImpl.waitForProcessExit0(JZ)I": function* (t, [pid, reap]): NativeGen { const child = children.get(Number(pid)); if (!child) return -2; From 56e7f4276525ab082f7b6f60386f018385945c5a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:17:56 -0600 Subject: [PATCH 06/20] Check that missing programs raise an exception. --- test/java/Processes.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/java/Processes.java b/test/java/Processes.java index d603e42..12557dd 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -27,7 +27,7 @@ public static void main(String[] args) throws Exception { new ProcessBuilder("/no-such-java-web-program").start(); System.out.println("missing=false"); } catch (IOException e) { - System.out.println("missing=" + e.getMessage().contains("error=2")); + 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)); From 64c44a2228cbb830ee67b8329a217da31bfa36dc Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:23:35 -0600 Subject: [PATCH 07/20] Merge stderr into piped stdout with file redirects. --- src/process-node.ts | 2 +- test/java/Processes.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 191baf9..1e36985 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -136,7 +136,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho stdio.push(hostFd); } } - if (redirectError && typeof stdio[1] === "number") stdio[2] = stdio[1]; + 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] }); const status = (code: number | null, signal: NodeJS.Signals | null) => code ?? 128 + ((os.constants.signals as Record)[signal ?? ""] ?? 0); diff --git a/test/java/Processes.java b/test/java/Processes.java index 12557dd..2061dc1 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -45,7 +45,7 @@ public static void main(String[] args) throws Exception { 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").redirectErrorStream(true).start(); + 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 redirect = new ProcessBuilder("echo", "file").redirectOutput(file.toFile()).start(); From 73a354b9fb04772fe0b98bf5aad9037068aedfa2 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:24:41 -0600 Subject: [PATCH 08/20] Enumerate Linux processes for process handles. --- src/process-node.ts | 22 ++++++++++++++++------ test/java/Processes.java | 1 + 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 1e36985..39ac11a 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -233,13 +233,23 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho return child && child.code === null && (start === 0n || start === child.start) && child.process.kill(force ? "SIGKILL" : "SIGTERM") ? 1 : 0; }, "java/lang/ProcessHandleImpl.getProcessPids0(J[J[J[J)I": (_t, [pid, pids, ppids, starts]) => { - 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; + let count = 0; + for (const name of fs.readdirSync("/proc")) { + if (!/^\d+$/.test(name)) continue; + try { + const stat = fs.readFileSync(`/proc/${name}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + const ppid = BigInt(fields[1]); + if (fields[0] === "Z" || (pid !== 0n && ppid !== pid)) continue; + if (count < pids.a.length) { + pids.a[count] = BigInt(name); + if (ppids) ppids.a[count] = ppid; + if (starts) starts.a[count] = children.get(Number(name))?.start ?? 0n; + } + count++; + } catch { /* process exited */ } } - return live.length; + return count; }, "java/lang/ProcessHandleImpl$Info.initIDs()V": () => undefined, "java/lang/ProcessHandleImpl$Info.info0(J)V": (t, [self, pid]) => { diff --git a/test/java/Processes.java b/test/java/Processes.java index 2061dc1..1474efb 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -53,6 +53,7 @@ public static void main(String[] args) throws Exception { Process sleep = new ProcessBuilder("sleep", "2").start(); 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(); From 077d89f5b4564ef5438663495b149e2193ac7c75 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:34:15 -0600 Subject: [PATCH 09/20] Report process start times and information from procfs. --- src/process-node.ts | 71 +++++++++++++++++++++------------------- test/java/Processes.java | 1 + 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 39ac11a..f703a47 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -103,6 +103,17 @@ 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 }; + } 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); @@ -184,7 +195,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho throw new PendingThrow("java/io/IOException", `error=${errno}, ${reason}`); } const pid = child.pid!; - const record: Child = { process: child, start: BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; + const record: Child = { process: child, start: processInfo(pid)?.start ?? BigInt(Date.now()), code: null, command: decode(prog), args: block(argBlock), exited: null! }; record.exited = exited.then((code) => { record.code = code; return new Promise((resolve) => setImmediate(() => resolve(code))); @@ -202,24 +213,16 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho }, "java/lang/ProcessHandleImpl.getCurrentPid0()J": () => BigInt(process.pid), "java/lang/ProcessHandleImpl.isAlive0(J)J": (_t, [pid]) => { - if (Number(pid) === process.pid) return 0n; const child = children.get(Number(pid)); if (child) return child.code === null ? child.start : -1n; - if (pid <= 0n) return -1n; - try { - const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); - return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ") ? -1n : 0n; - } catch { return -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; - if (pid <= 0n) return -1n; - try { - const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); - const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); - return fields[0] === "Z" ? -1n : BigInt(fields[1]); - } catch { return -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)); @@ -234,33 +237,35 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho }, "java/lang/ProcessHandleImpl.getProcessPids0(J[J[J[J)I": (_t, [pid, pids, ppids, starts]) => { let count = 0; - for (const name of fs.readdirSync("/proc")) { + let names: string[]; + try { names = fs.readdirSync("/proc"); } catch { names = []; } + for (const name of names) { if (!/^\d+$/.test(name)) continue; - try { - const stat = fs.readFileSync(`/proc/${name}/stat`, "utf8"); - const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); - const ppid = BigInt(fields[1]); - if (fields[0] === "Z" || (pid !== 0n && ppid !== pid)) continue; - if (count < pids.a.length) { - pids.a[count] = BigInt(name); - if (ppids) ppids.a[count] = ppid; - if (starts) starts.a[count] = children.get(Number(name))?.start ?? 0n; - } - count++; - } catch { /* process exited */ } + 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)); - if (!child || child.code !== null) return; - t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "startTime", child.start); - let command = child.command; - try { command = fs.readlinkSync(`/proc/${pid}/exe`); } catch { /* process exited */ } - t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "command", t.vm.str(command)); - t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "user", t.vm.str(os.userInfo().username)); - t.vm.setField(self, "java/lang/ProcessHandleImpl$Info", "arguments", t.vm.newObjectArray("java/lang/String", child.args.map((arg) => t.vm.str(arg)))); + const info = processInfo(pid); + if (!info || (child && child.code !== null)) return; + try { + const 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", "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/java/Processes.java b/test/java/Processes.java index 1474efb..dd95b98 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -51,6 +51,7 @@ public static void main(String[] args) throws Exception { Process redirect = new ProcessBuilder("echo", "file").redirectOutput(file.toFile()).start(); System.out.println("redirect=" + redirect.waitFor() + ":" + Files.readString(file).trim()); Process sleep = new ProcessBuilder("sleep", "2").start(); + System.out.println("info=" + sleep.info().command().isPresent() + ":" + sleep.info().startInstant().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())); From d7a4782c01022775d9dad8bde9278d797e974afb Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:34:30 -0600 Subject: [PATCH 10/20] Keep child enumeration available without procfs. --- src/process-node.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/process-node.ts b/src/process-node.ts index f703a47..3cda801 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -236,6 +236,15 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho return child && child.code === null && (start === 0n || start === child.start) && child.process.kill(force ? "SIGKILL" : "SIGTERM") ? 1 : 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 = []; } From 234bac3ebb5ece3607b1ce134eeee37e4494bcf3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:46:14 -0600 Subject: [PATCH 11/20] Report child CPU time from procfs. --- src/process-node.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/process-node.ts b/src/process-node.ts index 3cda801..859a3fb 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -111,7 +111,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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 }; + return { parent: BigInt(fields[1]), start: boot + BigInt(fields[19]) * 10n, + cpu: (BigInt(fields[11]) + BigInt(fields[12])) * 10000000n }; } catch { return null; } }; return { @@ -270,6 +271,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho const 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?.()) From e2a49aaa522c8c7aa47fd33190f6addb8db47c19 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:48:04 -0600 Subject: [PATCH 12/20] Check process information and merged output ordering. --- test/java/Processes.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/java/Processes.java b/test/java/Processes.java index dd95b98..267bd5c 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -48,10 +48,12 @@ public static void main(String[] args) throws Exception { 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 sleep = new ProcessBuilder("sleep", "2").start(); - System.out.println("info=" + sleep.info().command().isPresent() + ":" + sleep.info().startInstant().isPresent() + ":" + ProcessHandle.current().info().command().isPresent()); + 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())); From f80c9015250f38434343779d87d96d83566a2bf6 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:57:35 -0600 Subject: [PATCH 13/20] Run newly passing OpenJDK process tests. --- test/jtreg/passing.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index 3b6155c..87af69b 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 From a65905788c809369e1790d487025ac4e98038b56 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:03:07 -0600 Subject: [PATCH 14/20] Preserve the order of merged child output. --- src/process-node.ts | 71 +++++++++++++++++++++++++++++++++------- test/image.test.ts | 8 +++++ test/java/Processes.java | 5 +++ 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 859a3fb..821cc9f 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -1,6 +1,8 @@ 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"; @@ -97,7 +99,45 @@ class Pipe implements ProcessPipe { } } -type Child = { process: ChildProcess; start: bigint; exited: Promise; code: number | null; command: string; args: string[] }; +// 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") : []; @@ -120,7 +160,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho let command = decode(prog); let args = block(argBlock); const testJdk = t.vm.props.get("test.jdk"); - if (command === "java" || command === "/jdk/bin/java" || command === `${jdk}/bin/java` || (testJdk && command === `${testJdk}/bin/java`)) { + 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]; } @@ -130,7 +171,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho })); const descriptors = (fds as JArray).a as Int32Array; const opened: number[] = []; - const stdio: (number | "pipe")[] = []; + const stdio: (number | "pipe" | Socket)[] = []; + let merged: { reader: Socket; writer: Socket } | undefined; let pipelineInput: Pipe | undefined; try { for (let i = 0; i < 3; i++) { @@ -148,27 +190,30 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho stdio.push(hostFd); } } - if (redirectError) stdio[2] = stdio[1]; + 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[] = []; - let stdoutPipe: Pipe | undefined; for (let i = 0; i < 3; i++) { if (i === 2 && redirectError) { - if (stdoutPipe) stdoutPipe.attach(child.stdio[2] as Readable); - else if (stdio[2] === "pipe") (child.stdio[2] as Readable).on("data", (data: Buffer) => t.vm.opts.stdout(new Uint8Array(data))); descriptors[i] = -1; } else if (descriptors[i] < 0) { - const pipe = new Pipe(i === 0 ? null : child.stdio[i] as Readable, i === 0 ? child.stdio[i] as Writable : null); - if (i === 1) stdoutPipe = pipe; + 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; - (child.stdio[i] as Readable)?.on("data", (data: Buffer) => output(new Uint8Array(data))); + const stream = i === 1 && merged ? merged.reader : child.stdio[i] as Readable; + stream?.on("data", (data: Buffer) => output(new Uint8Array(data))); } if (i === 0 && descriptors[i] === 0 && stdio[i] === "pipe") { const input = child.stdio[i] as Writable; @@ -196,7 +241,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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), exited: null! }; + 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; return new Promise((resolve) => setImmediate(() => resolve(code))); @@ -205,6 +250,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho if (pipelineInput) pipelineInput.forward(child.stdin!); 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}`); @@ -268,7 +315,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho const info = processInfo(pid); if (!info || (child && child.code !== null)) return; try { - const command = fs.readlinkSync(`/proc/${pid}/exe`); + 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); diff --git a/test/image.test.ts b/test/image.test.ts index 0a5b1c6..f402254 100644 --- a/test/image.test.ts +++ b/test/image.test.ts @@ -131,6 +131,14 @@ test("dist/node.js and the java-web command run a program as HotSpot does", { sk } }); +test("dist/node.js starts child processes", { skip }, () => { + 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" }); + 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 index 267bd5c..067594b 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -9,6 +9,7 @@ static String output(Process p) throws Exception { } 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; } @@ -23,6 +24,10 @@ public static void main(String[] args) throws Exception { 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"); From f07d1988887fede369d34cd9a69f8e49438265f3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:15:22 -0600 Subject: [PATCH 15/20] Support descendant termination and JDK image input redirects. --- src/natives/io.ts | 3 ++- src/process-node.ts | 35 ++++++++++++++++++++++++++++++----- test/java/Processes.java | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 9915f2a..0d25128 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -43,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; @@ -204,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); diff --git a/src/process-node.ts b/src/process-node.ts index 821cc9f..13931fd 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -8,7 +8,7 @@ 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 { 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"; @@ -174,6 +174,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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]; @@ -185,9 +186,14 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho } else { const file = fileOf(t, fd); flushOrThrow(t, file); - const hostFd = fs.openSync(file.path, i === 0 ? fs.constants.O_RDONLY : fs.constants.O_WRONLY | (file.pos > 0 ? fs.constants.O_APPEND : 0)); - opened.push(hostFd); - stdio.push(hostFd); + 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") { @@ -248,6 +254,21 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho }); 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; + } + } finally { output.end(); } + })().catch(() => {}); + } return pid; } catch (e) { merged?.writer.destroy(); @@ -280,8 +301,12 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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)); - return child && child.code === null && (start === 0n || start === child.start) && child.process.kill(force ? "SIGKILL" : "SIGTERM") ? 1 : 0; + 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) { diff --git a/test/java/Processes.java b/test/java/Processes.java index 067594b..b04c388 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -37,6 +37,23 @@ public static void main(String[] args) throws Exception { 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(); + } + } Process exit = new ProcessBuilder("sh", "-c", "exit 17").start(); System.out.println("exit=" + exit.waitFor()); Path dir = Files.createTempDirectory("process-test"); @@ -57,6 +74,8 @@ public static void main(String[] args) throws Exception { 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())); From 4777990838d9d42e46d17f2efc2f23e99e5d12a8 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:36:08 -0600 Subject: [PATCH 16/20] Ignore -X and --add- options when java starts a child VM, as the java-web command does --- src/node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node.ts b/src/node.ts index fca22f9..1666ca0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -504,11 +504,11 @@ export function main(argv: string[]) { } else if (a === "--jdk") o.jdk = 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 (/^(-X|--add-|-verbose|-server|-client)/.test(a)) continue; else if (a === "--max-rss") o.maxRssMb = Number(argv[++i]); else { o.mainClass = a; i++; break; } } From 2347c749a797f5490558706030c7cbc4de94be73 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:36:30 -0600 Subject: [PATCH 17/20] Document child processes under Node --- README.md | 3 ++- docs/support.md | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 From 43c95ab2788dc1b70c78f9aed372abdf9e129b9c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:05:38 -0600 Subject: [PATCH 18/20] Guard inherited child output and run the process tests on Linux only --- src/process-node.ts | 3 ++- test/image.test.ts | 5 +++-- test/programs.test.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/process-node.ts b/src/process-node.ts index 13931fd..1f44b6b 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -219,7 +219,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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("data", (data: Buffer) => output(new Uint8Array(data))); + 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; diff --git a/test/image.test.ts b/test/image.test.ts index f402254..c4e60ec 100644 --- a/test/image.test.ts +++ b/test/image.test.ts @@ -131,11 +131,12 @@ test("dist/node.js and the java-web command run a program as HotSpot does", { sk } }); -test("dist/node.js starts child processes", { skip }, () => { +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" }); + 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 }); }); 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[] = []; From 99f4f3f34c58e852c6f28a9432ede36db7fdb1ec Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:08:39 -0600 Subject: [PATCH 19/20] Test inherited child output order and explain the exit wait --- src/process-node.ts | 2 ++ test/java/Processes.java | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/process-node.ts b/src/process-node.ts index 1f44b6b..c006caa 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -251,6 +251,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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); diff --git a/test/java/Processes.java b/test/java/Processes.java index b04c388..2782eb2 100644 --- a/test/java/Processes.java +++ b/test/java/Processes.java @@ -54,6 +54,11 @@ public static void main(String[] args) throws Exception { 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"); From c5223786db8d05f5e497c397f2acb24996b513b3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:15:46 -0600 Subject: [PATCH 20/20] Wait for drain when forwarding into a child's input --- src/process-node.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/process-node.ts b/src/process-node.ts index c006caa..1cac603 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -12,6 +12,10 @@ 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; @@ -82,6 +86,7 @@ class Pipe implements ProcessPipe { 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 { @@ -268,6 +273,7 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho 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(() => {});