From cf9644268fc3ac853c509827989bac8b2f5a6387 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:10:22 -0600 Subject: [PATCH 1/8] fix(io): close the host stdout or stderr when Java closes descriptor 1 or 2 --- src/jvm.ts | 2 ++ src/natives/io.ts | 4 ++-- src/node.ts | 21 +++++++++++++++++++++ src/process-node.ts | 6 ++---- test/java/CloseStdout.java | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 test/java/CloseStdout.java diff --git a/src/jvm.ts b/src/jvm.ts index 64ef3a7..532b492 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -58,6 +58,8 @@ export interface JvmOptions { loadNativeLibrary?: (path: string) => Promise; // told when Java closes a file it wrote fileClosed?: (path: string) => void; + // told when Java closes descriptor 1 or 2, its standard output or error + closeOutput?: (fd: number) => void; // runs before the first OutOfMemoryError the VM throws is created onOutOfMemory?: (message: string | null) => void; // log VM internals to the host console diff --git a/src/natives/io.ts b/src/natives/io.ts index fa6fa1a..2bb3ae2 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -493,7 +493,7 @@ export function closeFd(t: JThread, fd: number) { if (f.writable && f.path) t.vm.opts.fileClosed?.(f.path); // the descriptor is closed either way, as with close(2) if (!ok) throw writeFailed(t, f.path); - } + } else if (fd === 1 || fd === 2) t.vm.opts.closeOutput?.(fd); } // Open files follow their inode: a rename moves their path, an unlink drops it. @@ -687,7 +687,7 @@ export const natives: Natives = { const fd = t.vm.getField(fdObj, "java/io/FileDescriptor", "fd"); // closed even when close reports a failed write t.vm.setField(fdObj, "java/io/FileDescriptor", "fd", -1); - if (fd > 2) closeFd(t, fd); + if (fd > 0) closeFd(t, fd); }, [`${FOS}.open0(Ljava/lang/String;Z)V`]: (t, [self, path, append]) => diff --git a/src/node.ts b/src/node.ts index 36d3744..7041adf 100644 --- a/src/node.ts +++ b/src/node.ts @@ -400,6 +400,7 @@ const broken = new Set(); function hostOut(s: NodeJS.WriteStream) { if (!s.listeners("error").includes(onOutError)) s.on("error", onOutError); return (b: Uint8Array) => { + if (closed.has(s)) throw new PendingThrow("java/io/IOException", "Bad file descriptor"); if (broken.has(s)) throw new PendingThrow("java/io/IOException", "Broken pipe"); s.write(b); }; @@ -407,6 +408,22 @@ function hostOut(s: NodeJS.WriteStream) { function onOutError(this: NodeJS.WriteStream) { broken.add(this); } +// Java closing descriptor 1 or 2 closes the host's, as in HotSpot, so a reader sees end of file. +// /dev/null takes the number, so no file opened later gets the stream's writes. +const closed = new Set(); +function closeHostOut(s: NodeJS.WriteStream, fd: number) { + if (closed.has(s)) return; + closed.add(s); + const close = () => { + try { + fs.closeSync(fd); + const nul = fs.openSync("/dev/null", "w"); + if (nul !== fd) fs.closeSync(nul); + } catch { /* already closed */ } + }; + if (s.writableLength) s.once("drain", close); + else close(); +} // What HotSpot does on the first OutOfMemoryError: it prints the commands and // runs each through /bin/sh, with %p the process id, before throwing. @@ -600,6 +617,10 @@ export function nodeVm(o: LauncherOptions): JVM { }, stdout: o.stdout ?? hostOut(process.stdout), stderr: o.stderr ?? hostOut(process.stderr), + closeOutput: (fd) => { + if (fd === 1 && !o.stdout) closeHostOut(process.stdout, 1); + else if (fd === 2 && !o.stderr) closeHostOut(process.stderr, 2); + }, stdin: o.input ? fixedInput(o.input) : () => new Promise((resolve) => { const onData = (d: Buffer) => { cleanup(); resolve(new Uint8Array(d)); }; const onEnd = () => { cleanup(); resolve(null); }; diff --git a/src/process-node.ts b/src/process-node.ts index e8203ca..07cd75f 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -77,10 +77,8 @@ function execFailure(error: NodeJS.ErrnoException): string { return `Exec failed, error: ${-error.errno!} (${text}) `; } -// A child's output waits in memory up to this many bytes before the child is held back. More than a -// pipe holds, since a java-web child cannot close its host stdout: a parent that reads stdout to its -// end before stderr would otherwise wait on a child held back on stderr. -const HELD = 1 << 24; +// A child's output waits in memory up to this many bytes, what a Linux pipe holds, before the child is held back +const HELD = 1 << 16; class Pipe implements ProcessPipe { private chunks: Uint8Array[] = []; diff --git a/test/java/CloseStdout.java b/test/java/CloseStdout.java new file mode 100644 index 0000000..39c2e47 --- /dev/null +++ b/test/java/CloseStdout.java @@ -0,0 +1,36 @@ +import java.io.File; +import java.io.IOException; + +// A child that closes System.out ends its stdout at once: the parent reads it to the end before +// stderr, while the child writes more to stderr than a pipe holds. +public class CloseStdout { + public static void main(String[] args) throws Exception { + if (args.length > 0) { + System.out.print("before close"); + System.out.close(); + byte[] line = new byte[1023]; + java.util.Arrays.fill(line, (byte) 'e'); + for (int i = 0; i < 1024; i++) { + System.err.write(line); + System.err.write('\n'); + } + System.err.flush(); + try { + new java.io.FileOutputStream(java.io.FileDescriptor.out).write('x'); + System.out.println("write after close worked"); + } catch (IOException e) { + System.err.println("write after close: " + e.getMessage()); + } + return; + } + String java = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; + Process p = new ProcessBuilder(java, "-cp", System.getProperty("java.class.path"), "CloseStdout", "child").start(); + String out = new String(p.getInputStream().readAllBytes()); + byte[] err = p.getErrorStream().readAllBytes(); + System.out.println("stdout: " + out); + System.out.println("stderr bytes: " + err.length); + String text = new String(err); + System.out.println(text.substring(text.lastIndexOf('\n', text.length() - 2) + 1).trim()); + System.out.println("exit: " + p.waitFor()); + } +} From 107447656c5cd6ce901d9fbc9e18cbf75282550c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:22:54 -0600 Subject: [PATCH 2/8] test(jtreg): put LotsOfOutput back in passing.txt --- test/jtreg/passing.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index a836c85..5f24b7b 100644 --- a/test/jtreg/passing.txt +++ b/test/jtreg/passing.txt @@ -188,6 +188,7 @@ java/lang/RuntimeTests/exec/ConcurrentRead.java java/lang/RuntimeTests/exec/Duped.java java/lang/RuntimeTests/exec/ExitValue.java java/lang/RuntimeTests/exec/LotsOfDestroys.java +java/lang/RuntimeTests/exec/LotsOfOutput.java java/lang/RuntimeTests/loadLibrary/LoadLibraryTest.java java/lang/RuntimeTests/shutdown/ShutdownHooks.java java/lang/RuntimeTests/shutdown/ShutdownInterruptedMain.java From 6f6cbe5e08121c41a6884e1d25d3c7555807f000 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:29:07 -0600 Subject: [PATCH 3/8] fix(io): use the platform's null device and close on a stream error too --- src/node.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/node.ts b/src/node.ts index 7041adf..d53f34e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -409,20 +409,25 @@ function onOutError(this: NodeJS.WriteStream) { broken.add(this); } // Java closing descriptor 1 or 2 closes the host's, as in HotSpot, so a reader sees end of file. -// /dev/null takes the number, so no file opened later gets the stream's writes. +// The null device takes the number, so no file opened later gets the stream's writes. const closed = new Set(); function closeHostOut(s: NodeJS.WriteStream, fd: number) { if (closed.has(s)) return; closed.add(s); + let done = false; const close = () => { + if (done) return; + done = true; try { fs.closeSync(fd); - const nul = fs.openSync("/dev/null", "w"); + const nul = fs.openSync(os.devNull, "w"); if (nul !== fd) fs.closeSync(nul); } catch { /* already closed */ } }; - if (s.writableLength) s.once("drain", close); - else close(); + if (s.writableLength) { + s.once("drain", close); + s.once("error", close); + } else close(); } // What HotSpot does on the first OutOfMemoryError: it prints the commands and From fa4fb9955b0ec53e6b053527df6b1ffe5f6f0257 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:36:13 -0600 Subject: [PATCH 4/8] fix(io): close the host stream after its pending writes and test closing stderr too --- src/node.ts | 7 +++--- test/java/CloseStdout.java | 49 +++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/node.ts b/src/node.ts index d53f34e..d1aadab 100644 --- a/src/node.ts +++ b/src/node.ts @@ -424,10 +424,9 @@ function closeHostOut(s: NodeJS.WriteStream, fd: number) { if (nul !== fd) fs.closeSync(nul); } catch { /* already closed */ } }; - if (s.writableLength) { - s.once("drain", close); - s.once("error", close); - } else close(); + // runs once the writes before it are done + s.write("", close); + s.once("error", close); } // What HotSpot does on the first OutOfMemoryError: it prints the commands and diff --git a/test/java/CloseStdout.java b/test/java/CloseStdout.java index 39c2e47..d8af9ca 100644 --- a/test/java/CloseStdout.java +++ b/test/java/CloseStdout.java @@ -1,36 +1,47 @@ import java.io.File; +import java.io.FileDescriptor; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.util.Arrays; -// A child that closes System.out ends its stdout at once: the parent reads it to the end before -// stderr, while the child writes more to stderr than a pipe holds. +// A child that closes System.out (or System.err) ends that stream at once: the parent reads it to +// the end first, while the child writes more to the other stream than a pipe holds. public class CloseStdout { public static void main(String[] args) throws Exception { if (args.length > 0) { - System.out.print("before close"); - System.out.close(); + boolean out = args[0].equals("out"); + PrintStream closing = out ? System.out : System.err, other = out ? System.err : System.out; + closing.print("before close"); + closing.close(); byte[] line = new byte[1023]; - java.util.Arrays.fill(line, (byte) 'e'); + Arrays.fill(line, (byte) 'e'); for (int i = 0; i < 1024; i++) { - System.err.write(line); - System.err.write('\n'); + other.write(line); + other.write('\n'); } - System.err.flush(); + other.flush(); try { - new java.io.FileOutputStream(java.io.FileDescriptor.out).write('x'); - System.out.println("write after close worked"); + new FileOutputStream(out ? FileDescriptor.out : FileDescriptor.err).write('x'); + other.println("write after close worked"); } catch (IOException e) { - System.err.println("write after close: " + e.getMessage()); + other.println("write after close: " + e.getMessage()); } return; } String java = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; - Process p = new ProcessBuilder(java, "-cp", System.getProperty("java.class.path"), "CloseStdout", "child").start(); - String out = new String(p.getInputStream().readAllBytes()); - byte[] err = p.getErrorStream().readAllBytes(); - System.out.println("stdout: " + out); - System.out.println("stderr bytes: " + err.length); - String text = new String(err); - System.out.println(text.substring(text.lastIndexOf('\n', text.length() - 2) + 1).trim()); - System.out.println("exit: " + p.waitFor()); + for (String which : new String[] {"out", "err"}) { + Process p = new ProcessBuilder(java, "-cp", System.getProperty("java.class.path"), "CloseStdout", which).start(); + InputStream closed = which.equals("out") ? p.getInputStream() : p.getErrorStream(); + InputStream other = which.equals("out") ? p.getErrorStream() : p.getInputStream(); + String first = new String(closed.readAllBytes()); + byte[] rest = other.readAllBytes(); + System.out.println(which + " closed: " + first); + System.out.println("other bytes: " + rest.length); + String text = new String(rest); + System.out.println(text.substring(text.lastIndexOf('\n', text.length() - 2) + 1).trim()); + System.out.println("exit: " + p.waitFor()); + } } } From d56b38050933b6a2c2209dd2cdb26ddd5aa165bf Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:32:59 -0600 Subject: [PATCH 5/8] fix(io): put the null device on descriptor 1 or 2 even when a lower one is free --- src/node.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node.ts b/src/node.ts index d1aadab..5ec55e1 100644 --- a/src/node.ts +++ b/src/node.ts @@ -420,8 +420,13 @@ function closeHostOut(s: NodeJS.WriteStream, fd: number) { done = true; try { fs.closeSync(fd); - const nul = fs.openSync(os.devNull, "w"); - if (nul !== fd) fs.closeSync(nul); + // open takes the lowest free number: fill any free one below fd, then let those go + const below: number[] = []; + for (let nul = fs.openSync(os.devNull, "w"); nul !== fd; nul = fs.openSync(os.devNull, "w")) { + below.push(nul); + if (nul > fd) break; + } + for (const nul of below) fs.closeSync(nul); } catch { /* already closed */ } }; // runs once the writes before it are done From fecdb593794d32c3fb0dac657897ee9f378145bc Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:36:34 -0600 Subject: [PATCH 6/8] fix(io): close at once when the host stream has already ended --- src/node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node.ts b/src/node.ts index 5ec55e1..c27ebc8 100644 --- a/src/node.ts +++ b/src/node.ts @@ -429,6 +429,7 @@ function closeHostOut(s: NodeJS.WriteStream, fd: number) { for (const nul of below) fs.closeSync(nul); } catch { /* already closed */ } }; + if (s.destroyed || s.writableEnded) return close(); // runs once the writes before it are done s.write("", close); s.once("error", close); From 5906aa45225f2a3b08b26ac71e2f5b09d59dd6ce Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:38:37 -0600 Subject: [PATCH 7/8] fix(io): close the host descriptor only on the main thread --- src/node.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node.ts b/src/node.ts index c27ebc8..5909954 100644 --- a/src/node.ts +++ b/src/node.ts @@ -4,6 +4,7 @@ import { isAssertionFlag, parseAssertions } from "./assertions.ts"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; +import { isMainThread } from "node:worker_threads"; import * as path from "node:path"; import { deserialize, serialize, setFlagsFromString } from "node:v8"; import { runInNewContext } from "node:vm"; @@ -412,7 +413,8 @@ function onOutError(this: NodeJS.WriteStream) { // The null device takes the number, so no file opened later gets the stream's writes. const closed = new Set(); function closeHostOut(s: NodeJS.WriteStream, fd: number) { - if (closed.has(s)) return; + // a Worker's stdout and stderr are not descriptors 1 and 2, which belong to the whole process + if (!isMainThread || closed.has(s)) return; closed.add(s); let done = false; const close = () => { From e64b4b0e5f7f22768a5d366c5c371e403b2756df Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:44:40 -0600 Subject: [PATCH 8/8] fix(io): also close when the host stream closes without an error --- src/node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node.ts b/src/node.ts index 5909954..6a17023 100644 --- a/src/node.ts +++ b/src/node.ts @@ -435,6 +435,7 @@ function closeHostOut(s: NodeJS.WriteStream, fd: number) { // runs once the writes before it are done s.write("", close); s.once("error", close); + s.once("close", close); } // What HotSpot does on the first OutOfMemoryError: it prints the commands and