From 1717bd34e826189122cc4882c36fba0c6ecb19ab Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:24:45 -0600 Subject: [PATCH 01/16] Stream host file operations through open descriptors. --- src/fs.ts | 15 +++++++++ src/natives/io.ts | 61 +++++++++++++++++++++++++++++++++++- src/natives/nio.ts | 10 ++++-- src/node.ts | 14 +++++++++ test/java/StreamingFile.java | 32 +++++++++++++++++++ 5 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 test/java/StreamingFile.java diff --git a/src/fs.ts b/src/fs.ts index d534dd9..9f7a98b 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -24,8 +24,17 @@ export interface Stat { // browser too. Host devices are never touched. export const DEVICES = new Set(["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"]); +export interface FileHandle { + size(): number; + read(dst: Uint8Array, off: number, len: number, pos: number): number; + write(src: Uint8Array, pos: number): void; + truncate(size: number): void; + close(): void; +} + export interface Backend { readonly writable: boolean; + openFile?(path: string, write: boolean, truncate: boolean): FileHandle; // A host backend can follow symlinks before resolving parent components. resolvePath?(path: string): string | null; stat(path: string): Stat | null; @@ -150,6 +159,12 @@ export class VFS { return r ? r[0].read(r[1]) : null; } + openFile(path: string, write: boolean, truncate: boolean): FileHandle | undefined { + const r = this.resolve(path); + if (!r || !r[0].writable || !r[0].openFile) return undefined; + return r[0].openFile(r[1], write, truncate); + } + // Whether part of a file can be read without reading all of it. canReadRange(path: string): boolean { const r = this.resolve(path); diff --git a/src/natives/io.ts b/src/natives/io.ts index c94ed93..fbb5ec2 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -4,7 +4,7 @@ import { JArray, JObject } from "../runtime.ts"; import { JThread, PendingThrow } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; -import type { Stat, VFS } from "../fs.ts"; +import type { FileHandle, Stat, VFS } from "../fs.ts"; import { closeSockFd } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; import type { Natives } from "./util.ts"; @@ -70,6 +70,47 @@ export class OpenFile { if (this.pos > n) this.pos = n; this.dirty = true; } + + retain() {} + release() {} + close() {} +} + +class HostFile extends OpenFile { + private refs = 1; + private handle: FileHandle; + constructor(path: string, writable: boolean, handle: FileHandle, contents?: Contents) { + super(path, new Uint8Array(0), writable, contents); + this.handle = handle; + } + override get size() { return this.handle.size(); } + override set size(_n: number) { throw new Error("Use setLength"); } + private fail(e: unknown): never { + const code = (e as { code?: string }).code ?? "EIO"; + throw new PendingThrow("java/io/IOException", ERRNO[code] ?? "Input/output error"); + } + override read(dst: Uint8Array, off: number, len: number): number { + try { + const n = this.handle.read(dst, off, len, this.pos); + this.pos += n; + return n || -1; + } catch (e) { return this.fail(e); } + } + override write(src: Uint8Array) { + try { + this.handle.write(src, this.pos); + this.pos += src.length; + } catch (e) { this.fail(e); } + } + override setLength(n: number) { + try { + this.handle.truncate(n); + if (this.pos > n) this.pos = n; + } catch (e) { this.fail(e); } + } + override retain() { this.refs++; } + override release() { if (--this.refs === 0) this.handle.close(); } + override close() { this.release(); } } // Reads and writes a character device as a stream. @@ -159,6 +200,23 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo syncPath(t, abs); const st = fs.stat(abs); if (st?.dir) notFound(path, "Is a directory"); + if (!st?.device) { + try { + const handle = fs.openFile(abs, write, truncate); + if (handle) { + const shared = [...table(t).files.values()].find((o) => o.path === abs && o instanceof HostFile)?.contents; + const f = new HostFile(abs, write, handle, shared); + if (append) f.pos = f.size; + const tab = table(t); + const fd = tab.next++; + tab.files.set(fd, f); + return fd; + } + } catch (e) { + const code = (e as { code?: string }).code ?? "EIO"; + notFound(path, ERRNO[code] ?? "Input/output error"); + } + } const shared = st ? [...table(t).files.values()].find((o) => o.path === abs && o.constructor === OpenFile)?.contents : undefined; if (st?.device || (st && !shared && !write && st.size >= LAZY_MIN && fs.canReadRange(abs))) { const tab = table(t); @@ -251,6 +309,7 @@ export function closeFd(t: JThread, fd: number) { if (f) { const ok = flush(t, f); tab.files.delete(fd); + f.close(); 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); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index f359835..6375bf2 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -428,12 +428,18 @@ Object.assign(natives, { const n = Math.min(f.size - Number(pos), Number(len)); if (n > 0) f.read(t.vm.mem.u8, addr, n); f.pos = save; - if (prot === MAP_RW) mappings(t).set(addr, { f, pos: Number(pos), len: Number(len) }); + if (prot === MAP_RW) { + f.retain(); + mappings(t).set(addr, { f, pos: Number(pos), len: Number(len) }); + } return BigInt(addr); }, [`${UFD}unmap0(JJ)I`]: (t: JThread, [addr]: any[]) => { const m = mappings(t).get(Number(addr)); - if (m) writeBack(t, m, Number(addr), Number(addr), m.len); + if (m) { + try { writeBack(t, m, Number(addr), Number(addr), m.len); } + finally { m.f.release(); } + } mappings(t).delete(Number(addr)); t.vm.mem.freeBlock(Number(addr)); return 0; diff --git a/src/node.ts b/src/node.ts index 595bffb..58d21f2 100644 --- a/src/node.ts +++ b/src/node.ts @@ -37,6 +37,20 @@ export class NodeBackend implements Backend { private host(p: string) { return this.root === "/" ? p : this.root + p; } + openFile(p: string, write: boolean, truncate: boolean) { + const flags = write ? fs.constants.O_RDWR | fs.constants.O_CREAT | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; + const fd = fs.openSync(this.host(p), flags, 0o666); + return { + size: () => fs.fstatSync(fd).size, + read: (dst: Uint8Array, off: number, len: number, pos: number) => fs.readSync(fd, dst, off, len, pos), + write: (src: Uint8Array, pos: number) => { + let n = 0; + while (n < src.length) n += fs.writeSync(fd, src, n, src.length - n, pos + n); + }, + truncate: (size: number) => fs.ftruncateSync(fd, size), + close: () => fs.closeSync(fd), + }; + } resolvePath(p: string): string | null { if (this.root !== "/") return null; try { diff --git a/test/java/StreamingFile.java b/test/java/StreamingFile.java new file mode 100644 index 0000000..ff2edc7 --- /dev/null +++ b/test/java/StreamingFile.java @@ -0,0 +1,32 @@ +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; + +public class StreamingFile { + public static void main(String[] args) throws Exception { + Path path = Files.createTempFile("streaming-file", ".bin"); + try (RandomAccessFile first = new RandomAccessFile(path.toFile(), "rw"); + RandomAccessFile second = new RandomAccessFile(path.toFile(), "rw")) { + byte[] block = new byte[1024 * 1024]; + for (int i = 0; i < block.length; i++) block[i] = (byte) i; + for (int i = 0; i < 16; i++) first.write(block); + first.seek(20L * 1024 * 1024 + 3); + first.write(123); + System.out.println(second.length()); + second.seek(19L * 1024 * 1024); + System.out.println(second.read() + " " + second.read()); + FileChannel channel = second.getChannel(); + ByteBuffer b = ByteBuffer.allocate(4); + channel.read(b, 20L * 1024 * 1024); + System.out.println(b.get(0) + " " + b.get(3)); + channel.truncate(1024 * 1024 + 1); + first.setLength(2 * 1024 * 1024); + first.seek(1024 * 1024); + System.out.println(first.read() + " " + first.read() + " " + second.length()); + } finally { + Files.deleteIfExists(path); + } + } +} From e34cdef55e06560352dcea98eb19372d542afef4 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:28:59 -0600 Subject: [PATCH 02/16] Preserve host file access modes. --- src/fs.ts | 6 +++--- src/natives/io.ts | 6 +++--- src/natives/nio.ts | 2 +- src/node.ts | 11 ++++++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 9f7a98b..8dc9b39 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -34,7 +34,7 @@ export interface FileHandle { export interface Backend { readonly writable: boolean; - openFile?(path: string, write: boolean, truncate: boolean): FileHandle; + openFile?(path: string, write: boolean, truncate: boolean, readable: boolean): FileHandle; // A host backend can follow symlinks before resolving parent components. resolvePath?(path: string): string | null; stat(path: string): Stat | null; @@ -159,10 +159,10 @@ export class VFS { return r ? r[0].read(r[1]) : null; } - openFile(path: string, write: boolean, truncate: boolean): FileHandle | undefined { + openFile(path: string, write: boolean, truncate: boolean, readable: boolean): FileHandle | undefined { const r = this.resolve(path); if (!r || !r[0].writable || !r[0].openFile) return undefined; - return r[0].openFile(r[1], write, truncate); + return r[0].openFile(r[1], write, truncate, readable); } // Whether part of a file can be read without reading all of it. diff --git a/src/natives/io.ts b/src/natives/io.ts index fbb5ec2..60681d7 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -194,7 +194,7 @@ function notFound(path: string, why = "No such file or directory"): never { } // Opens a path and returns its descriptor number. -export function openFile(t: JThread, path: string, write: boolean, truncate: boolean, append: boolean): number { +export function openFile(t: JThread, path: string, write: boolean, truncate: boolean, append: boolean, readable = true): number { const fs = t.vm.fs; const abs = resolvePath(t, path); syncPath(t, abs); @@ -202,7 +202,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo if (st?.dir) notFound(path, "Is a directory"); if (!st?.device) { try { - const handle = fs.openFile(abs, write, truncate); + const handle = fs.openFile(abs, write, truncate, readable); if (handle) { const shared = [...table(t).files.values()].find((o) => o.path === abs && o instanceof HostFile)?.contents; const f = new HostFile(abs, write, handle, shared); @@ -503,7 +503,7 @@ export const natives: Natives = { }, [`${FOS}.open0(Ljava/lang/String;Z)V`]: (t, [self, path, append]) => - setFd(t, self, FOS, openFile(t, t.vm.jsString(path), true, !append, append === 1)), + setFd(t, self, FOS, openFile(t, t.vm.jsString(path), true, !append, append === 1, false)), [`${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"); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 6375bf2..ee820a3 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -152,7 +152,7 @@ export const natives: Natives = { if (st?.dir && write) unixError(EISDIR); if ((write || !st) && t.vm.fs.denied(abs)) unixError(EACCES); try { - const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0); + const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0, (flags & O_ACCMODE) !== 1); fileOf(t, fd).ino = fileInode(t, abs); return fd; } catch (e) { diff --git a/src/node.ts b/src/node.ts index 58d21f2..14eebb9 100644 --- a/src/node.ts +++ b/src/node.ts @@ -37,9 +37,14 @@ export class NodeBackend implements Backend { private host(p: string) { return this.root === "/" ? p : this.root + p; } - openFile(p: string, write: boolean, truncate: boolean) { - const flags = write ? fs.constants.O_RDWR | fs.constants.O_CREAT | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; - const fd = fs.openSync(this.host(p), flags, 0o666); + openFile(p: string, write: boolean, truncate: boolean, readable: boolean) { + const flags = write ? (readable ? fs.constants.O_RDWR : fs.constants.O_WRONLY) | fs.constants.O_CREAT | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; + let fd: number; + try { fd = fs.openSync(this.host(p), flags, 0o666); } + catch (e) { + this.writeFailure = [p, (e as NodeJS.ErrnoException).code ?? "EIO"]; + throw e; + } return { size: () => fs.fstatSync(fd).size, read: (dst: Uint8Array, off: number, len: number, pos: number) => fs.readSync(fd, dst, off, len, pos), From 4f97ae646b25b6b0eab2102f7d776fb141208f7a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:30:52 -0600 Subject: [PATCH 03/16] Keep host streaming limited to regular files. --- src/natives/io.ts | 2 +- src/node.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 60681d7..41dc0af 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -285,7 +285,7 @@ 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", ENAMETOOLONG: "File name too long", ENOTDIR: "Not a directory", - EIO: "Input/output error", ELOOP: "Too many levels of symbolic links", + EIO: "Input/output error", ELOOP: "Too many levels of symbolic links", EOPNOTSUPP: "Operation not supported", }; // Where Java learns of a write that failed: HotSpot reports it from write(), diff --git a/src/node.ts b/src/node.ts index 14eebb9..c1e970e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -40,7 +40,16 @@ export class NodeBackend implements Backend { openFile(p: string, write: boolean, truncate: boolean, readable: boolean) { const flags = write ? (readable ? fs.constants.O_RDWR : fs.constants.O_WRONLY) | fs.constants.O_CREAT | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; let fd: number; - try { fd = fs.openSync(this.host(p), flags, 0o666); } + try { + const host = this.host(p); + const stat = fs.statSync(host, { throwIfNoEntry: false }); + if (stat && !stat.isFile()) throw Object.assign(new Error("Not a regular file"), { code: "EOPNOTSUPP" }); + fd = fs.openSync(host, flags | fs.constants.O_NONBLOCK, 0o666); + if (!fs.fstatSync(fd).isFile()) { + fs.closeSync(fd); + throw Object.assign(new Error("Not a regular file"), { code: "EOPNOTSUPP" }); + } + } catch (e) { this.writeFailure = [p, (e as NodeJS.ErrnoException).code ?? "EIO"]; throw e; From 60a1afda871d8bfaa92e2a8fb1120b9e584a15d1 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:16:28 -0600 Subject: [PATCH 04/16] Run the interruptible copy regression test. --- test/jtreg/passing.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index 4d4e403..8fc2b18 100644 --- a/test/jtreg/passing.txt +++ b/test/jtreg/passing.txt @@ -2113,6 +2113,7 @@ java/nio/file/Files/CopyToNonDefaultFS.java java/nio/file/Files/CreateDirectories.java java/nio/file/Files/CustomOptions.java java/nio/file/Files/FileAttributes.java +java/nio/file/Files/InterruptCopy.java java/nio/file/Files/Links.java java/nio/file/Files/Misc.java java/nio/file/Files/Mismatch.java From 29a705fa1dc5774b565c5c703da8f9899cdfeb0a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:26:55 -0600 Subject: [PATCH 05/16] Keep small files in memory and stream a file to the host once it passes 4 MiB --- src/fs.ts | 5 ++ src/natives/io.ts | 168 +++++++++++++++++++++++------------ src/snapshot.ts | 1 + test/java/StreamingFile.java | 18 ++++ 4 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 8dc9b39..4416cf4 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -159,6 +159,11 @@ export class VFS { return r ? r[0].read(r[1]) : null; } + canOpenFile(path: string): boolean { + const r = this.resolve(path); + return !!r && r[0].writable && !!r[0].openFile; + } + openFile(path: string, write: boolean, truncate: boolean, readable: boolean): FileHandle | undefined { const r = this.resolve(path); if (!r || !r[0].writable || !r[0].openFile) return undefined; diff --git a/src/natives/io.ts b/src/natives/io.ts index 41dc0af..dfbacce 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -4,16 +4,52 @@ import { JArray, JObject } from "../runtime.ts"; import { JThread, PendingThrow } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; +import type { JVM } from "../jvm.ts"; import type { FileHandle, Stat, VFS } from "../fs.ts"; import { closeSockFd } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; import type { Natives } from "./util.ts"; // A file's bytes, shared by every descriptor open on it, as an inode is. +// Small files live in data; past STREAM_MIN the file is read and written on +// the host through one handle instead, so its size does not cost memory. interface Contents { data: Uint8Array; size: number; dirty: boolean; + refs: number; + host?: FileHandle; +} + +// Opens a file's host handle when it outgrows memory. Kept apart from the +// contents, which a boot snapshot saves. +const hostOpeners = new WeakMap FileHandle | undefined>(); +const closesHosts = new WeakSet(); + +// Host handles of files the program leaves open close when its VM exits. +function closeHostsOnExit(vm: JVM) { + if (closesHosts.has(vm)) return; + closesHosts.add(vm); + vm.exited.then(() => { + const tab = vm.state.get("fds") as FdTable | undefined; + for (const f of tab?.files.values() ?? []) { + f.contents.host?.close(); + f.contents.host = undefined; + } + }); +} + +// Bigger files stream to the host where the backend can: small ones stay in +// memory, where many small reads and writes cost no system call each. +const STREAM_MIN = 4 << 20; + +function hostCall(call: () => T): T { + try { return call(); } + catch (e) { + if (e instanceof PendingThrow) throw e; + const code = (e as { code?: string }).code ?? "EIO"; + throw new PendingThrow("java/io/IOException", ERRNO[code] ?? "Input/output error"); + } } export class OpenFile { @@ -26,7 +62,8 @@ export class OpenFile { detached?: { path: string; stat: Stat; writable: boolean }; constructor(path: string, data: Uint8Array, writable: boolean, contents?: Contents) { this.path = path; - this.contents = contents ?? { data, size: data.length, dirty: false }; + this.contents = contents ?? { data, size: data.length, dirty: false, refs: 0 }; + this.contents.refs++; this.writable = writable; } @@ -40,12 +77,25 @@ export class OpenFile { read(dst: Uint8Array, off: number, len: number): number { if (this.pos >= this.size) return -1; const n = Math.min(len, this.size - this.pos); + const host = this.contents.host; + if (host) { + const got = hostCall(() => host.read(dst, off, n, this.pos)); + this.pos += got; + return got || -1; + } dst.set(this.data.subarray(this.pos, this.pos + n), off); this.pos += n; return n; } write(src: Uint8Array) { + const host = this.contents.host; + if (host) { + hostCall(() => host.write(src, this.pos)); + this.pos += src.length; + if (this.pos > this.size) this.size = this.pos; + return; + } // past the end, the gap reads as zeros (the buffer may hold bytes from before a truncation) if (this.pos > this.size) this.data.fill(0, this.size, Math.min(this.pos, this.data.length)); const end = this.pos + src.length; @@ -58,9 +108,18 @@ export class OpenFile { this.pos = end; if (end > this.size) this.size = end; this.dirty = true; + if (this.size >= STREAM_MIN) this.stream(); } setLength(n: number) { + const host = this.contents.host; + if (host) { + hostCall(() => host.truncate(n)); + this.size = n; + if (this.pos > n) this.pos = n; + return; + } + if (n >= STREAM_MIN && this.stream()) return this.setLength(n); if (n > this.data.length) { const next = new Uint8Array(n); next.set(this.data.subarray(0, this.size)); @@ -71,46 +130,38 @@ export class OpenFile { this.dirty = true; } - retain() {} - release() {} - close() {} -} - -class HostFile extends OpenFile { - private refs = 1; - private handle: FileHandle; - constructor(path: string, writable: boolean, handle: FileHandle, contents?: Contents) { - super(path, new Uint8Array(0), writable, contents); - this.handle = handle; - } - override get size() { return this.handle.size(); } - override set size(_n: number) { throw new Error("Use setLength"); } - private fail(e: unknown): never { - const code = (e as { code?: string }).code ?? "EIO"; - throw new PendingThrow("java/io/IOException", ERRNO[code] ?? "Input/output error"); - } - override read(dst: Uint8Array, off: number, len: number): number { + // Moves the bytes to the host file; on failure they stay in memory and the + // flush that writes them out reports the error. + private stream(): boolean { + const c = this.contents; + const open = hostOpeners.get(c); + if (!open || !this.path) return false; + let host: FileHandle | undefined; try { - const n = this.handle.read(dst, off, len, this.pos); - this.pos += n; - return n || -1; - } catch (e) { return this.fail(e); } - } - override write(src: Uint8Array) { - try { - this.handle.write(src, this.pos); - this.pos += src.length; - } catch (e) { this.fail(e); } + host = open(); + if (!host) throw new Error("no host file"); + host.write(c.data.subarray(0, c.size), 0); + host.truncate(c.size); + } catch { + host?.close(); + hostOpeners.delete(c); + return false; + } + c.host = host; + c.data = new Uint8Array(0); + c.dirty = false; + return true; } - override setLength(n: number) { - try { - this.handle.truncate(n); - if (this.pos > n) this.pos = n; - } catch (e) { this.fail(e); } + + // a mapping keeps the file open after its descriptor closes + retain() { this.contents.refs++; } + release() { + if (--this.contents.refs === 0 && this.contents.host) { + this.contents.host.close(); + this.contents.host = undefined; + } } - override retain() { this.refs++; } - override release() { if (--this.refs === 0) this.handle.close(); } - override close() { this.release(); } + close() { this.release(); } } // Reads and writes a character device as a stream. @@ -200,23 +251,6 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo syncPath(t, abs); const st = fs.stat(abs); if (st?.dir) notFound(path, "Is a directory"); - if (!st?.device) { - try { - const handle = fs.openFile(abs, write, truncate, readable); - if (handle) { - const shared = [...table(t).files.values()].find((o) => o.path === abs && o instanceof HostFile)?.contents; - const f = new HostFile(abs, write, handle, shared); - if (append) f.pos = f.size; - const tab = table(t); - const fd = tab.next++; - tab.files.set(fd, f); - return fd; - } - } catch (e) { - const code = (e as { code?: string }).code ?? "EIO"; - notFound(path, ERRNO[code] ?? "Input/output error"); - } - } const shared = st ? [...table(t).files.values()].find((o) => o.path === abs && o.constructor === OpenFile)?.contents : undefined; if (st?.device || (st && !shared && !write && st.size >= LAZY_MIN && fs.canReadRange(abs))) { const tab = table(t); @@ -224,6 +258,17 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo return tab.next++; } if (write && fs.denied(abs)) notFound(path, "Permission denied"); + if (st && !shared && !truncate && st.size >= STREAM_MIN && fs.canOpenFile(abs)) { + const host = openHost(fs, abs, write, readable); + if (host) { + closeHostsOnExit(t.vm); + const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host }); + if (append) f.pos = f.size; + const tab = table(t); + tab.files.set(tab.next, f); + return tab.next++; + } + } let data: Uint8Array; if (!st) { if (!write) notFound(path); @@ -238,6 +283,10 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo else data = fs.read(abs) ?? notFound(path, "Operation not supported"); } const f = new OpenFile(abs, data, write, shared); + if (!shared && fs.canOpenFile(abs)) { + const vm = t.vm; + hostOpeners.set(f.contents, () => (closeHostsOnExit(vm), fs.openFile(abs, true, false, true))); + } if (truncate && st) f.setLength(0); if (append) f.pos = f.size; const tab = table(t); @@ -246,6 +295,15 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo return fd; } +// Read and write if the host allows, so other descriptors can share the handle. +function openHost(fs: VFS, abs: string, write: boolean, readable: boolean): FileHandle | undefined { + try { return fs.openFile(abs, write, false, true); } + catch { + try { return readable ? undefined : fs.openFile(abs, write, false, false); } + catch { return undefined; } + } +} + // Writes reach the file system when a file is closed. Another open or a stat // of the same path sees them at once on a real OS, so write them out first. export function syncPath(t: JThread, abs: string) { diff --git a/src/snapshot.ts b/src/snapshot.ts index c5e982e..eeef378 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -289,6 +289,7 @@ export function dumpVM(vm: JVM, main: JThread, key: string): SnapshotImage { classCount: vm.classCount, state: [...s].filter(([k, x]) => { if (k === "socks" && x.socks.size) throw new Error("snapshot: a socket is open"); + if (k === "fds" && [...x.files.values()].some((f: any) => f.contents.host)) throw new Error("snapshot: a file streamed to the host is open"); return !TRANSIENT.has(k) && !k.startsWith("parked:"); }).map(([k, v]) => [k, e.enc(v, `state.${k}`)]), pending: e.enc(refs?.pending ?? [], "refs.pending"), diff --git a/test/java/StreamingFile.java b/test/java/StreamingFile.java index ff2edc7..85426f0 100644 --- a/test/java/StreamingFile.java +++ b/test/java/StreamingFile.java @@ -28,5 +28,23 @@ public static void main(String[] args) throws Exception { } finally { Files.deleteIfExists(path); } + Path big = Files.createTempFile("streaming-file", ".bin"); + try { + try (RandomAccessFile out = new RandomAccessFile(big.toFile(), "rw")) { + out.setLength(6L << 20); + out.seek((6L << 20) - 1); + out.write(7); + } + try (RandomAccessFile again = new RandomAccessFile(big.toFile(), "rw")) { + again.seek((6L << 20) - 1); + System.out.println(again.read() + " " + again.length()); + again.write(9); + again.setLength(1000); + System.out.println(again.length() + " " + Files.size(big)); + } + System.out.println(Files.size(big)); + } finally { + Files.deleteIfExists(big); + } } } From 74e2c5040ced6323d565feb495f146ac06fd0b5d Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:38:01 -0600 Subject: [PATCH 06/16] Mark large files opened for append as appending, as smaller ones are --- src/natives/io.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index cfb777c..98c4eb2 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -287,7 +287,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo if (host) { closeHostsOnExit(t.vm); const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host }); - if (append) f.pos = f.size; + if (append) { f.pos = f.size; f.append = true; } const tab = table(t); tab.files.set(tab.next, f); return tab.next++; From 0f4cf368c97848425cc526f14c66988e1032f846 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:48:21 -0600 Subject: [PATCH 07/16] Open a growing file's host handle with its descriptor's access, and note what host handles assume --- src/fs.ts | 1 + src/natives/io.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fs.ts b/src/fs.ts index 4416cf4..aa522da 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -24,6 +24,7 @@ export interface Stat { // browser too. Host devices are never touched. export const DEVICES = new Set(["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"]); +// Errors carry an errno name in `code`, as Node's do. export interface FileHandle { size(): number; read(dst: Uint8Array, off: number, len: number, pos: number): number; diff --git a/src/natives/io.ts b/src/natives/io.ts index 98c4eb2..677858c 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -36,6 +36,7 @@ export function processPipe(t: JThread, fd: number): ProcessPipe | undefined { // A file's bytes, shared by every descriptor open on it, as an inode is. // Small files live in data; past STREAM_MIN the file is read and written on // the host through one handle instead, so its size does not cost memory. +// size is kept here in both cases: changes made outside the VM are not seen. interface Contents { data: Uint8Array; size: number; @@ -309,7 +310,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo const f = new OpenFile(abs, data, write, shared); if (!shared && fs.canOpenFile(abs)) { const vm = t.vm; - hostOpeners.set(f.contents, () => (closeHostsOnExit(vm), fs.openFile(abs, true, false, true))); + hostOpeners.set(f.contents, () => (closeHostsOnExit(vm), openHost(fs, abs, true, readable))); } if (truncate && st) f.setLength(0); if (append) { f.pos = f.size; f.append = true; } From c1b3078f172ccbb548f83d60f9c9ff0aa9552e28 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:57:05 -0600 Subject: [PATCH 08/16] Refuse writes through a RandomAccessFile opened read-only, as HotSpot does --- src/natives/io.ts | 15 ++++++++++----- test/java/StreamingFile.java | 6 ++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 677858c..cd2810e 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -341,6 +341,13 @@ export function setMtime(t: JThread, abs: string, ms: number, ns?: bigint): bool return t.vm.fs.setMtime(abs, ms, ns); } +// write(2) on a descriptor opened read-only fails with EBADF. +function writableFile(t: JThread, fd: number): OpenFile { + const f = fileOf(t, fd); + if (!f.writable) throw new PendingThrow("java/io/IOException", "Bad file descriptor"); + return f; +} + export function fileOf(t: JThread, fd: number): OpenFile { const f = table(t).files.get(fd); if (!f) throw new PendingThrow("java/io/IOException", "Stream Closed"); @@ -472,9 +479,7 @@ function writeOut(t: JThread, fd: number, bytes: Uint8Array) { 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"); - f.write(bytes); + writableFile(t, fd).write(bytes); } } @@ -658,11 +663,11 @@ export const natives: Natives = { if (off < 0 || len < 0 || off + len > a.a.length) throw new PendingThrow("java/lang/IndexOutOfBoundsException"); return len === 0 ? 0 : fileOf(t, fdOf(t, self, RAF)).read(u8(a), off, len); }, - [`${RAF}.write0(I)V`]: (t, [self, b]) => fileOf(t, fdOf(t, self, RAF)).write(Uint8Array.of(b & 0xff)), + [`${RAF}.write0(I)V`]: (t, [self, b]) => writableFile(t, fdOf(t, self, RAF)).write(Uint8Array.of(b & 0xff)), [`${RAF}.writeBytes0([BII)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"); - fileOf(t, fdOf(t, self, RAF)).write(u8(a, off, len)); + writableFile(t, fdOf(t, self, RAF)).write(u8(a, off, len)); }, [`${RAF}.getFilePointer()J`]: (t, [self]) => BigInt(fileOf(t, fdOf(t, self, RAF)).pos), [`${RAF}.seek0(J)V`]: (t, [self, pos]) => { fileOf(t, fdOf(t, self, RAF)).pos = Number(pos); }, diff --git a/test/java/StreamingFile.java b/test/java/StreamingFile.java index 85426f0..93f4a15 100644 --- a/test/java/StreamingFile.java +++ b/test/java/StreamingFile.java @@ -43,6 +43,12 @@ public static void main(String[] args) throws Exception { System.out.println(again.length() + " " + Files.size(big)); } System.out.println(Files.size(big)); + try (RandomAccessFile ro = new RandomAccessFile(big.toFile(), "r")) { + ro.write(1); + System.out.println("wrote"); + } catch (java.io.IOException e) { + System.out.println(e.getMessage() + " " + Files.size(big)); + } } finally { Files.deleteIfExists(big); } From fc1dc3598ab1173012c2ced0e751b7e13b7ab97e Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:03:18 -0600 Subject: [PATCH 09/16] Report writes through host file handles to watch services --- src/fs.ts | 10 +++++++++- test/java/WatchService.java | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/fs.ts b/src/fs.ts index 8833641..f9dbdd4 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -179,7 +179,15 @@ export class VFS { openFile(path: string, write: boolean, truncate: boolean, readable: boolean): FileHandle | undefined { const r = this.resolve(path); if (!r || !r[0].writable || !r[0].openFile) return undefined; - return r[0].openFile(r[1], write, truncate, readable); + const h = r[0].openFile(r[1], write, truncate, readable); + // writes through the handle are changes watchers see, as writes through VFS.write are + return { + size: () => h.size(), + read: (dst, off, len, pos) => h.read(dst, off, len, pos), + close: () => h.close(), + write: (src, pos) => { h.write(src, pos); this.changed(path, "modify"); }, + truncate: (size) => { h.truncate(size); this.changed(path, "modify"); }, + }; } // Whether part of a file can be read without reading all of it. diff --git a/test/java/WatchService.java b/test/java/WatchService.java index 63ec522..ac8f0dd 100644 --- a/test/java/WatchService.java +++ b/test/java/WatchService.java @@ -26,6 +26,13 @@ public static void main(String[] args) throws Exception { Files.writeString(file, "first"); Files.writeString(file, "second"); Files.delete(file); + // past 4 MiB the file streams to the host; its writes still report changes + Path big = dir.resolve("big"); + try (var out = Files.newOutputStream(big)) { + byte[] block = new byte[1 << 20]; + for (int i = 0; i < 6; i++) out.write(block); + } + Files.delete(big); Thread.sleep(100); collect(watch, events); } From 256368e1f950d3795db1750cbad6e962b1be7db6 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:11:23 -0600 Subject: [PATCH 10/16] Refuse a snapshot while a file that may still stream to the host is open --- src/natives/io.ts | 5 +++++ src/snapshot.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index cd2810e..7733b0e 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -50,6 +50,11 @@ interface Contents { const hostOpeners = new WeakMap FileHandle | undefined>(); const closesHosts = new WeakSet(); +// A snapshot cannot hold a host handle, nor the opener a file may still need. +export function streams(f: OpenFile): boolean { + return !!f.contents.host || hostOpeners.has(f.contents); +} + // Host handles of files the program leaves open close when its VM exits. function closeHostsOnExit(vm: JVM) { if (closesHosts.has(vm)) return; diff --git a/src/snapshot.ts b/src/snapshot.ts index eeef378..fd78a76 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -8,6 +8,7 @@ import { parseClass } from "./classfile.ts"; import type { JVM } from "./jvm.ts"; import { platformProperties, vmProperties } from "./natives/misc.ts"; import { restorePending, restoreReference } from "./natives/ref.ts"; +import { streams } from "./natives/io.ts"; import { MAX_HEAP } from "./runtime.ts"; import { JArray, JClass, JField, JMethod, JObject, Monitor } from "./runtime.ts"; import { Frame, JThread, NativeFrame, STATUS_RUNNABLE } from "./thread.ts"; @@ -289,7 +290,7 @@ export function dumpVM(vm: JVM, main: JThread, key: string): SnapshotImage { classCount: vm.classCount, state: [...s].filter(([k, x]) => { if (k === "socks" && x.socks.size) throw new Error("snapshot: a socket is open"); - if (k === "fds" && [...x.files.values()].some((f: any) => f.contents.host)) throw new Error("snapshot: a file streamed to the host is open"); + if (k === "fds" && [...x.files.values()].some(streams)) throw new Error("snapshot: a host file that streams is open"); return !TRANSIENT.has(k) && !k.startsWith("parked:"); }).map(([k, v]) => [k, e.enc(v, `state.${k}`)]), pending: e.enc(refs?.pending ?? [], "refs.pending"), From e76dfb1c29ab1f19b3a5f3114c2ebc5322997afb Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:13:06 -0600 Subject: [PATCH 11/16] Give only writable descriptors a host opener, so open read-only files do not block snapshots --- src/natives/io.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 7733b0e..fed1159 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -313,7 +313,8 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo else data = fs.read(abs) ?? notFound(path, "Operation not supported"); } const f = new OpenFile(abs, data, write, shared); - if (!shared && fs.canOpenFile(abs)) { + // only a writer can grow a file, so read-only files never hold an opener + if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs)) { const vm = t.vm; hostOpeners.set(f.contents, () => (closeHostsOnExit(vm), openHost(fs, abs, true, readable))); } From e33e762462f6ddc74181674947d183c619eda78b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:22:10 -0600 Subject: [PATCH 12/16] Give a writer that joins a read-only host handle a writable one, and stream a renamed file at its new path --- src/natives/io.ts | 17 +++++++++++++---- test/java/StreamingFile.java | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index fed1159..4621709 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -43,11 +43,12 @@ interface Contents { dirty: boolean; refs: number; host?: FileHandle; + hostWritable?: boolean; } // Opens a file's host handle when it outgrows memory. Kept apart from the // contents, which a boot snapshot saves. -const hostOpeners = new WeakMap FileHandle | undefined>(); +const hostOpeners = new WeakMap FileHandle | undefined>(); const closesHosts = new WeakSet(); // A snapshot cannot hold a host handle, nor the opener a file may still need. @@ -168,7 +169,7 @@ export class OpenFile { if (!open || !this.path) return false; let host: FileHandle | undefined; try { - host = open(); + host = open(this.path); if (!host) throw new Error("no host file"); host.write(c.data.subarray(0, c.size), 0); host.truncate(c.size); @@ -178,6 +179,7 @@ export class OpenFile { return false; } c.host = host; + c.hostWritable = true; c.data = new Uint8Array(0); c.dirty = false; return true; @@ -292,7 +294,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo const host = openHost(fs, abs, write, readable); if (host) { closeHostsOnExit(t.vm); - const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host }); + const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host, hostWritable: write }); if (append) { f.pos = f.size; f.append = true; } const tab = table(t); tab.files.set(tab.next, f); @@ -312,11 +314,18 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo else if (truncate) data = new Uint8Array(0); else data = fs.read(abs) ?? notFound(path, "Operation not supported"); } + // a writer joining a file read through a read-only host handle needs a writable one + if (write && shared?.host && !shared.hostWritable) { + const host = openHost(fs, abs, true, true) ?? notFound(path, "Permission denied"); + shared.host.close(); + shared.host = host; + shared.hostWritable = true; + } const f = new OpenFile(abs, data, write, shared); // only a writer can grow a file, so read-only files never hold an opener if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs)) { const vm = t.vm; - hostOpeners.set(f.contents, () => (closeHostsOnExit(vm), openHost(fs, abs, true, readable))); + hostOpeners.set(f.contents, (current) => (closeHostsOnExit(vm), openHost(fs, current, true, readable))); } if (truncate && st) f.setLength(0); if (append) { f.pos = f.size; f.append = true; } diff --git a/test/java/StreamingFile.java b/test/java/StreamingFile.java index 93f4a15..3f1df4b 100644 --- a/test/java/StreamingFile.java +++ b/test/java/StreamingFile.java @@ -43,6 +43,28 @@ public static void main(String[] args) throws Exception { System.out.println(again.length() + " " + Files.size(big)); } System.out.println(Files.size(big)); + try (RandomAccessFile ro = new RandomAccessFile(big.toFile(), "r")) { + ro.setLength(0); + } catch (java.io.IOException e) { + System.out.println(e.getMessage()); + } + Files.write(big, new byte[5 << 20]); + try (RandomAccessFile reader = new RandomAccessFile(big.toFile(), "r"); + RandomAccessFile writer = new RandomAccessFile(big.toFile(), "rw")) { + writer.seek(1 << 20); + writer.write(42); + reader.seek(1 << 20); + System.out.println(reader.read() + " " + reader.length()); + } + Path moved = big.resolveSibling(big.getFileName() + ".moved"); + try (var out = new java.io.FileOutputStream(big.toFile())) { + out.write(new byte[1 << 20]); + Files.move(big, moved); + for (int i = 0; i < 5; i++) out.write(new byte[1 << 20]); + } + System.out.println(Files.exists(big) + " " + Files.size(moved)); + Files.delete(moved); + Files.write(big, new byte[1000]); try (RandomAccessFile ro = new RandomAccessFile(big.toFile(), "r")) { ro.write(1); System.out.println("wrote"); From c521672449bce780f389d6f6ee942ff7585e0a67 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:25:02 -0600 Subject: [PATCH 13/16] Pass the writer's flag to the host opener it registers --- src/natives/io.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 4621709..792d028 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -325,7 +325,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo // only a writer can grow a file, so read-only files never hold an opener if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs)) { const vm = t.vm; - hostOpeners.set(f.contents, (current) => (closeHostsOnExit(vm), openHost(fs, current, true, readable))); + hostOpeners.set(f.contents, (current) => (closeHostsOnExit(vm), openHost(fs, current, write, readable))); } if (truncate && st) f.setLength(0); if (append) { f.pos = f.size; f.append = true; } From 0f1f50a06953f32c70939e650902d7f354c2d65c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:27:43 -0600 Subject: [PATCH 14/16] Open host handles with the access asked for, and widen a shared one when a descriptor needs more --- src/natives/io.ts | 30 +++++++++++++++++------------- test/java/StreamingFile.java | 5 +++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 792d028..639a6d5 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -44,6 +44,7 @@ interface Contents { refs: number; host?: FileHandle; hostWritable?: boolean; + hostReadable?: boolean; } // Opens a file's host handle when it outgrows memory. Kept apart from the @@ -294,7 +295,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo const host = openHost(fs, abs, write, readable); if (host) { closeHostsOnExit(t.vm); - const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host, hostWritable: write }); + const f = new OpenFile(abs, new Uint8Array(0), write, { data: new Uint8Array(0), size: host.size(), dirty: false, refs: 0, host, hostWritable: write, hostReadable: readable }); if (append) { f.pos = f.size; f.append = true; } const tab = table(t); tab.files.set(tab.next, f); @@ -314,18 +315,25 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo else if (truncate) data = new Uint8Array(0); else data = fs.read(abs) ?? notFound(path, "Operation not supported"); } - // a writer joining a file read through a read-only host handle needs a writable one - if (write && shared?.host && !shared.hostWritable) { - const host = openHost(fs, abs, true, true) ?? notFound(path, "Permission denied"); + // a descriptor joining a host handle that lacks its access reopens it with both + if (shared?.host && ((write && !shared.hostWritable) || (readable && !shared.hostReadable))) { + const w = write || !!shared.hostWritable, r = readable || !!shared.hostReadable; + const host = openHost(fs, abs, w, r) ?? notFound(path, "Permission denied"); shared.host.close(); - shared.host = host; - shared.hostWritable = true; + Object.assign(shared, { host, hostWritable: w, hostReadable: r }); } const f = new OpenFile(abs, data, write, shared); // only a writer can grow a file, so read-only files never hold an opener if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs)) { const vm = t.vm; - hostOpeners.set(f.contents, (current) => (closeHostsOnExit(vm), openHost(fs, current, write, readable))); + const c = f.contents; + hostOpeners.set(c, (current) => { + closeHostsOnExit(vm); + // descriptors already sharing the contents may read through the handle + const host = openHost(fs, current, write, true); + c.hostReadable = !!host; + return host ?? openHost(fs, current, write, false); + }); } if (truncate && st) f.setLength(0); if (append) { f.pos = f.size; f.append = true; } @@ -335,13 +343,9 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo return fd; } -// Read and write if the host allows, so other descriptors can share the handle. function openHost(fs: VFS, abs: string, write: boolean, readable: boolean): FileHandle | undefined { - try { return fs.openFile(abs, write, false, true); } - catch { - try { return readable ? undefined : fs.openFile(abs, write, false, false); } - catch { return undefined; } - } + try { return fs.openFile(abs, write, false, readable); } + catch { return undefined; } } // Writes reach the file system when a file is closed. Another open or a stat diff --git a/test/java/StreamingFile.java b/test/java/StreamingFile.java index 3f1df4b..1815829 100644 --- a/test/java/StreamingFile.java +++ b/test/java/StreamingFile.java @@ -56,6 +56,11 @@ public static void main(String[] args) throws Exception { reader.seek(1 << 20); System.out.println(reader.read() + " " + reader.length()); } + try (var out = new java.io.FileOutputStream(big.toFile(), true); + var in = new java.io.FileInputStream(big.toFile())) { + out.write(7); + System.out.println(in.skip(5 << 20) + " " + in.read() + " " + in.read()); + } Path moved = big.resolveSibling(big.getFileName() + ".moved"); try (var out = new java.io.FileOutputStream(big.toFile())) { out.write(new byte[1 << 20]); From 863dd8e3205b2c33929adacff5998aeae047281c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:31:01 -0600 Subject: [PATCH 15/16] Refuse channel writes and truncation on descriptors not open for writing, as write(2) and ftruncate(2) do --- src/natives/nio.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index f388cef..306a16b 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -355,6 +355,7 @@ function writeAt(t: JThread, fdObj: JObject, addr: bigint, len: number, pos: num return len; } const f = chanFile(t, fdObj); + if (!f.writable) throw new PendingThrow("java/io/IOException", "Bad file descriptor"); const save = f.pos; if (pos !== null) f.pos = pos; f.write(data); @@ -407,7 +408,10 @@ Object.assign(natives, { return BigInt(f.pos); }, [`${UFD}truncate0(Ljava/io/FileDescriptor;J)I`]: (t: JThread, [fd, size]: any[]) => { - chanFile(t, fd).setLength(Number(size)); + const f = chanFile(t, fd); + // ftruncate(2) on a descriptor not open for writing + if (!f.writable) throw new PendingThrow("java/io/IOException", "Invalid argument"); + f.setLength(Number(size)); return 0; }, [`${UFD}size0(Ljava/io/FileDescriptor;)J`]: (t: JThread, [fd]: any[]) => BigInt(chanFile(t, fd).size), From 622e772c1b1188bae8722c0ccbea8d055838c58e Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:41:32 -0600 Subject: [PATCH 16/16] Return 0 for a zero-length read of a file streamed to the host --- src/natives/io.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 639a6d5..4a6823f 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -110,7 +110,7 @@ export class OpenFile { if (this.pos >= this.size) return -1; const n = Math.min(len, this.size - this.pos); const host = this.contents.host; - if (host) { + if (host && n) { const got = hostCall(() => host.read(dst, off, n, this.pos)); this.pos += got; return got || -1;