From f86e7cc4a5664acbd1abc02fa736c5ad4837ddca Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:53:24 -0600 Subject: [PATCH 1/2] Report a write the host refuses as an IOException from close, sync and force instead of losing it. --- src/fs.ts | 6 ++++++ src/natives/io.ts | 27 ++++++++++++++++++++++++--- src/natives/nio.ts | 5 ++--- src/node.ts | 4 +++- test/full/Full.java | 37 +++++++++++++++++++++++++++++++++++++ test/programs.test.ts | 16 ++++++++++++++++ 6 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 test/full/Full.java diff --git a/src/fs.ts b/src/fs.ts index 5d1fc19..755fb59 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -20,6 +20,8 @@ export interface Backend { readRange?(path: string, off: number, len: number): Uint8Array | null; list(path: string): string[] | null; write?(path: string, data: Uint8Array): boolean; + // the errno code (such as "ENOSPC") of the last write that failed + writeError?: string; mkdir?(path: string): boolean; remove?(path: string): boolean; rename?(from: string, to: string): boolean; @@ -139,6 +141,10 @@ export class VFS { return !!r && r[0].writable && !!r[0].write?.(r[1], data); } + writeError(path: string): string | undefined { + return this.resolve(path)?.[0].writeError; + } + mkdir(path: string): boolean { const r = this.resolve(path); return !!r && r[0].writable && !!r[0].mkdir?.(r[1]); diff --git a/src/natives/io.ts b/src/natives/io.ts index bb978c8..2071a97 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -190,7 +190,8 @@ export function fileOf(t: JThread, fd: number): OpenFile { return f; } -export function flush(t: JThread, f: OpenFile) { +// Writes the file's buffered bytes; false if the host refused them. +export function flush(t: JThread, f: OpenFile): boolean { if (f.dirty && f.writable && f.path) { const data = f.data.slice(0, f.size); let ok = t.vm.fs.write(f.path, data); @@ -201,7 +202,25 @@ export function flush(t: JThread, f: OpenFile) { t.vm.fs.chmod(f.path, () => mode); } if (ok) f.dirty = false; + return ok; } + return true; +} + +const ERRNO: Record = { + ENOSPC: "No space left on device", EFBIG: "File too large", EDQUOT: "Disk quota exceeded", + EACCES: "Permission denied", EPERM: "Operation not permitted", EROFS: "Read-only file system", + ENOENT: "No such file or directory", +}; + +// Where Java learns of a write that failed: HotSpot reports it from write(), +// java-web, which buffers, from the flush, sync or close that writes it out. +export function flushOrThrow(t: JThread, f: OpenFile) { + if (!flush(t, f)) throw writeFailed(t, f.path); +} + +function writeFailed(t: JThread, path: string): PendingThrow { + return new PendingThrow("java/io/IOException", ERRNO[t.vm.fs.writeError(path) ?? ""] ?? "Input/output error"); } export function newFd(t: JThread): number { @@ -213,9 +232,11 @@ export function closeFd(t: JThread, fd: number) { const tab = table(t); const f = tab.files.get(fd); if (f) { - flush(t, f); + const ok = flush(t, f); tab.files.delete(fd); 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); } } @@ -308,7 +329,7 @@ export const natives: Natives = { "java/io/FileDescriptor.sync0()V": (t, [fdObj]) => { const fd = t.vm.getField(fdObj, "java/io/FileDescriptor", "fd"); const f = table(t).files.get(fd); - if (f) flush(t, f); + if (f) flushOrThrow(t, f); }, "java/io/FileDescriptor.close0()V": (t, [fdObj]) => { const fd = t.vm.getField(fdObj, "java/io/FileDescriptor", "fd"); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 091a0b4..e1d2486 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -5,7 +5,7 @@ import type { JObject } from "../runtime.ts"; import { preCloseSock } from "../sockets.ts"; import { sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; -import { closeFd, endsInDot, fileOf, flush, openFile, removePath, renamePath, resolvePath, setMtime, syncPath } from "./io.ts"; +import { closeFd, endsInDot, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath } from "./io.ts"; import type { OpenFile } from "./io.ts"; import type { Natives } from "./util.ts"; @@ -338,8 +338,7 @@ Object.assign(natives, { [`${UFD}writev0(Ljava/io/FileDescriptor;JI)J`]: (t: JThread, [fd, addr, n]: any[]) => sockWritev(t, fd, addr, n) ?? vector(t, addr, n, (base, len) => writeAt(t, fd, base, len, null)), [`${UFD}force0(Ljava/io/FileDescriptor;Z)I`]: (t: JThread, [fd]: any[]) => { - const f = chanFile(t, fd); - flush(t, f); + flushOrThrow(t, chanFile(t, fd)); return 0; }, [`${UFD}seek0(Ljava/io/FileDescriptor;J)J`]: (t: JThread, [fd, off]: any[]) => { diff --git a/src/node.ts b/src/node.ts index 67a84b0..d5ccc49 100644 --- a/src/node.ts +++ b/src/node.ts @@ -68,11 +68,13 @@ export class NodeBackend implements Backend { if (!this.writable) this.lists.set(p, names); return names; } + writeError?: string; write(p: string, data: Uint8Array) { try { fs.writeFileSync(this.host(p), data); return true; - } catch { + } catch (e) { + this.writeError = (e as NodeJS.ErrnoException).code; return false; } } diff --git a/test/full/Full.java b/test/full/Full.java new file mode 100644 index 0000000..2f84d01 --- /dev/null +++ b/test/full/Full.java @@ -0,0 +1,37 @@ +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +// Writes past the file size limit (ulimit -f), so the host refuses them. HotSpot +// reports that from write(); java-web, which buffers, from close, sync or force. +public class Full { + public static void main(String[] args) throws Exception { + Path p = Path.of(args[0]); + try { + try (var out = new FileOutputStream(p.toFile())) { + out.write(new byte[8192]); + } + System.out.println("closed"); + } catch (IOException e) { + System.out.println("close: " + e.getMessage()); + } + try (var out = new FileOutputStream(p.toFile())) { + out.write(new byte[8192]); + out.getFD().sync(); + System.out.println("synced"); + } catch (IOException e) { + System.out.println("sync: " + e.getMessage()); + } + try (var ch = FileChannel.open(p, StandardOpenOption.WRITE)) { + ByteBuffer b = ByteBuffer.allocate(8192); + while (b.hasRemaining()) ch.write(b); + ch.force(true); + System.out.println("forced"); + } catch (IOException e) { + System.out.println("force: " + e.getMessage()); + } + } +} diff --git a/test/programs.test.ts b/test/programs.test.ts index d092ae3..7430300 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -76,6 +76,22 @@ test("-Xmx caps an allocation under the command", () => { assert.match(max.stdout, /^max 18874368$/m); }); +// ulimit -f makes the host refuse writes past 4 KB; SIGXFSZ ignored, they fail with EFBIG +test("writes the host refuses", { skip: process.platform !== "linux" }, () => { + const classes = join(root, "build", "test-full"); + execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "full", "Full.java")]); + const dir = mkdtempSync(join(tmpdir(), "java-web-full-")); + const limited = (cmd: string, args: string[]) => + spawnSync("bash", ["-c", 'trap "" XFSZ; ulimit -f 4; exec "$@"', "bash", cmd, ...args, "-cp", classes, "Full", join(dir, "out.bin")], { encoding: "utf8" }); + try { + const ref = limited(hotspot("java"), []); + const got = limited(process.execPath, [join(root, "src", "node.ts")]); + assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); + } finally { + rmSync(dir, { recursive: true }); + } +}); + test("stdout closed by its reader", async () => { const classes = join(root, "build", "test-pipe"); execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "pipe", "Pipe.java")]); From a894b4ae598ee6034f2d649382dba1fad18374e0 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:15:00 -0600 Subject: [PATCH 2/2] Keep write errors per path, report failed write-back from MappedByteBuffer.force, and invalidate a descriptor before a close that can throw. --- src/fs.ts | 7 ++++--- src/natives/io.ts | 5 +++-- src/natives/nio.ts | 15 +++++++++------ src/node.ts | 8 ++++++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 755fb59..fb352e0 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -20,8 +20,8 @@ export interface Backend { readRange?(path: string, off: number, len: number): Uint8Array | null; list(path: string): string[] | null; write?(path: string, data: Uint8Array): boolean; - // the errno code (such as "ENOSPC") of the last write that failed - writeError?: string; + // the errno code (such as "ENOSPC") of the last failed write to path + writeError?(path: string): string | undefined; mkdir?(path: string): boolean; remove?(path: string): boolean; rename?(from: string, to: string): boolean; @@ -142,7 +142,8 @@ export class VFS { } writeError(path: string): string | undefined { - return this.resolve(path)?.[0].writeError; + const r = this.resolve(path); + return r?.[0].writeError?.(r[1]); } mkdir(path: string): boolean { diff --git a/src/natives/io.ts b/src/natives/io.ts index 2071a97..ebd1dcc 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -219,7 +219,7 @@ export function flushOrThrow(t: JThread, f: OpenFile) { if (!flush(t, f)) throw writeFailed(t, f.path); } -function writeFailed(t: JThread, path: string): PendingThrow { +export function writeFailed(t: JThread, path: string): PendingThrow { return new PendingThrow("java/io/IOException", ERRNO[t.vm.fs.writeError(path) ?? ""] ?? "Input/output error"); } @@ -333,8 +333,9 @@ export const natives: Natives = { }, "java/io/FileDescriptor.close0()V": (t, [fdObj]) => { const fd = t.vm.getField(fdObj, "java/io/FileDescriptor", "fd"); - if (fd > 2) closeFd(t, fd); + // closed even when close reports a failed write t.vm.setField(fdObj, "java/io/FileDescriptor", "fd", -1); + if (fd > 2) closeFd(t, fd); }, [`${FOS}.open0(Ljava/lang/String;Z)V`]: (t, [self, path, append]) => diff --git a/src/natives/nio.ts b/src/natives/nio.ts index e1d2486..783181d 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -5,7 +5,7 @@ import type { JObject } from "../runtime.ts"; import { preCloseSock } from "../sockets.ts"; import { sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; -import { closeFd, endsInDot, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath } from "./io.ts"; +import { closeFd, endsInDot, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; import type { OpenFile } from "./io.ts"; import type { Natives } from "./util.ts"; @@ -267,15 +267,16 @@ function mappings(t: JThread): Map { } // Copies mapped bytes from addr on into the file, which stays the size it is. -function writeBack(t: JThread, m: Mapping, base: number, addr: number, len: number) { +// false if the host refused the bytes +function writeBack(t: JThread, m: Mapping, base: number, addr: number, len: number): boolean { const at = m.pos + addr - base; const n = Math.min(len, m.f.size - at); - if (n <= 0) return; + if (n <= 0) return true; const save = m.f.pos; m.f.pos = at; m.f.write(t.vm.mem.u8.slice(addr, addr + n)); m.f.pos = save; - flush(t, m.f); + return flush(t, m.f); } function readAt(t: JThread, fdObj: JObject, addr: bigint, len: number, pos: number | null): number { @@ -386,7 +387,7 @@ Object.assign(natives, { const from = Number(addr), to = from + Number(len); for (const [base, m] of mappings(t)) { const start = Math.max(from, base), end = Math.min(to, base + m.len); - if (start < end) writeBack(t, m, base, start, end - start); + if (start < end && !writeBack(t, m, base, start, end - start)) throw writeFailed(t, m.f.path); } }, "java/nio/MappedMemoryUtils.isLoaded0(JJJ)Z": () => 1, @@ -406,7 +407,9 @@ Object.assign(natives, { "sun/nio/ch/UnixDispatcher.init()V": () => undefined, "sun/nio/ch/UnixDispatcher.preClose0(Ljava/io/FileDescriptor;)V": (t: JThread, [fd]: any[]) => preCloseSock(t.vm.state, fdNum(t, fd)), "sun/nio/ch/UnixDispatcher.close0(Ljava/io/FileDescriptor;)V": (t: JThread, [fd]: any[]) => { - closeFd(t, fdNum(t, fd)); + const n = fdNum(t, fd); + // closed even when close reports a failed write t.vm.setField(fd, FD, "fd", -1); + closeFd(t, n); }, }); diff --git a/src/node.ts b/src/node.ts index d5ccc49..5608b75 100644 --- a/src/node.ts +++ b/src/node.ts @@ -68,16 +68,20 @@ export class NodeBackend implements Backend { if (!this.writable) this.lists.set(p, names); return names; } - writeError?: string; + private writeErrors = new Map(); write(p: string, data: Uint8Array) { try { fs.writeFileSync(this.host(p), data); + this.writeErrors.delete(p); return true; } catch (e) { - this.writeError = (e as NodeJS.ErrnoException).code; + this.writeErrors.set(p, (e as NodeJS.ErrnoException).code ?? "EIO"); return false; } } + writeError(p: string) { + return this.writeErrors.get(p); + } denied(p: string) { const host = this.host(p); try {