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..6a17023 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"; @@ -400,6 +401,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 +409,34 @@ 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. +// 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) { + // 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 = () => { + if (done) return; + done = true; + try { + fs.closeSync(fd); + // 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 */ } + }; + if (s.destroyed || s.writableEnded) return close(); + // 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 // runs each through /bin/sh, with %p the process id, before throwing. @@ -600,6 +630,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..d8af9ca --- /dev/null +++ b/test/java/CloseStdout.java @@ -0,0 +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 (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) { + 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]; + Arrays.fill(line, (byte) 'e'); + for (int i = 0; i < 1024; i++) { + other.write(line); + other.write('\n'); + } + other.flush(); + try { + new FileOutputStream(out ? FileDescriptor.out : FileDescriptor.err).write('x'); + other.println("write after close worked"); + } catch (IOException e) { + other.println("write after close: " + e.getMessage()); + } + return; + } + String java = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; + 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()); + } + } +} 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