From 997f45084367ca34b28428a9ac7a2cfdb79ce6a6 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:32:11 -0600 Subject: [PATCH 01/12] Implement VFS backed Linux watch service --- src/fs.ts | 25 ++++++- src/natives/sockets.ts | 143 ++++++++++++++++++++++++++++++++++++ test/java/WatchService.java | 35 +++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 test/java/WatchService.java diff --git a/src/fs.ts b/src/fs.ts index ba90da9..391447c 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -61,6 +61,17 @@ export class VFS { private setTimes = new Map(); private mounts: { prefix: string; backend: Backend }[] = []; private hidden: string[] = []; + private watchers = new Set<(path: string, event: "create" | "modify" | "delete") => void>(); + + watchChanges(watcher: (path: string, event: "create" | "modify" | "delete") => void): () => void { + this.watchers.add(watcher); + return () => this.watchers.delete(watcher); + } + + private changed(path: string, event: "create" | "modify" | "delete") { + path = normalize(path); + for (const watcher of this.watchers) watcher(path, event); + } resolvePath(path: string): string { const normalized = normalize(path); @@ -165,7 +176,11 @@ export class VFS { write(path: string, data: Uint8Array): boolean { const r = this.resolve(path); - return !!r && r[0].writable && !!r[0].write?.(r[1], data); + if (!r || !r[0].writable) return false; + const exists = !!r[0].stat(r[1]); + if (!r[0].write?.(r[1], data)) return false; + this.changed(path, exists ? "modify" : "create"); + return true; } writeError(path: string): string | undefined { @@ -175,19 +190,24 @@ export class VFS { mkdir(path: string): boolean { const r = this.resolve(path); - return !!r && r[0].writable && !!r[0].mkdir?.(r[1]); + if (!r || !r[0].writable || !r[0].mkdir?.(r[1])) return false; + this.changed(path, "create"); + return true; } remove(path: string): boolean { const r = this.resolve(path); if (!r || !r[0].writable || !r[0].remove?.(r[1])) return false; this.setTimes.delete(normalize(path)); + this.changed(path, "delete"); return true; } rename(from: string, to: string): boolean { const a = this.resolve(from), b = this.resolve(to); if (!a || !b || a[0] !== b[0] || !a[0].writable || !a[0].rename?.(a[1], b[1])) return false; + this.changed(from, "delete"); + this.changed(to, "create"); // set times move with the files, which keep their times const src = normalize(from), dst = normalize(to); const under = (p: string, root: string) => p === root || p.startsWith(root + "/"); @@ -208,6 +228,7 @@ export class VFS { setMtime(path: string, ms: number, ns?: bigint): boolean { const r = this.resolve(path); if (!r || !r[0].writable || !r[0].setMtime?.(r[1], ms)) return false; + this.changed(path, "modify"); const n = normalize(path); const st = ns === undefined ? null : r[0].stat(r[1]); // only a time the host kept, give or take its rounding; a host can clamp it diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 23408e6..768e756 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -13,11 +13,63 @@ import type { Datagram, Endpoint } from "../sockets.ts"; import { closeFd, newFd, openFile, resolvePath } from "./io.ts"; import { inet4, inetBytes, socketAddress } from "./net.ts"; import type { Natives } from "./util.ts"; +import { normalize } from "../fs.ts"; const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; const EEXIST = 17, EBADF = 9, EPROTO = 71; +type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; remove: () => void }; +const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; +const IN_CREATE = 0x100, IN_DELETE = 0x200, IN_Q_OVERFLOW = 0x4000; +const IN_MASK_ADD = IN_MODIFY | IN_ATTRIB | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE | IN_DELETE; +const MAX_INOTIFY_BYTES = 1 << 20; + +function inotify(t: JThread): Inotify | undefined { + return t.vm.state.get("inotify") as Inotify | undefined; +} + +function initInotify(t: JThread): number { + const old = inotify(t); + if (old) return old.sock.fd; + const sock = new Sock("stream"); + sock.connected = true; + sock.blocking = false; + const state: Inotify = { sock, watches: new Map(), paths: new Map(), nextWd: 1, remove: () => {} }; + state.remove = t.vm.fs.watchChanges((path, event) => { + const slash = path.lastIndexOf("/"); + const parent = slash === 0 ? "/" : path.slice(0, slash); + const name = new TextEncoder().encode(path.slice(slash + 1)); + for (const [wd, watch] of state.watches) { + if (watch.path !== parent) continue; + const mask = event === "create" ? IN_CREATE : event === "delete" ? IN_DELETE : IN_MODIFY; + if (!(watch.mask & mask)) continue; + const len = (name.length + 1 + 3) & ~3; + const bytes = new Uint8Array(16 + len); + const view = new DataView(bytes.buffer); + view.setInt32(0, wd, true); + view.setUint32(4, mask, true); + view.setUint32(8, 0, true); + view.setUint32(12, len, true); + bytes.set(name, 16); + if (sock.rxBytes + bytes.length > MAX_INOTIFY_BYTES) { + sock.rx = []; + sock.rxBytes = 0; + const overflow = new Uint8Array(16); + new DataView(overflow.buffer).setUint32(4, IN_Q_OVERFLOW, true); + sock.push(overflow); + break; + } + sock.push(bytes); + } + }); + const fd = register(t, sock); + sock.impl = state; + t.vm.state.set("inotify", state); + t.vm.exited.then(() => state.remove()); + return fd; +} + // the JDK thread token for signalling a thread out of a blocking call const blocked = new WeakMap void>(); @@ -143,6 +195,36 @@ function* pollSock(t: JThread, s: Sock, events: number, timeout: number): Native } } +function* pollPair(t: JThread, a: Sock, b: Sock): NativeGen { + for (;;) { + const ready = (a.poll() ? 1 : 0) + (b.poll() ? 1 : 0); + if (ready) return ready; + yield t.block(new Promise((resolve) => { + let done = false; + const wake = () => { + if (done) return; + done = true; + a.waiters.delete(wake); + b.waiters.delete(wake); + blocked.delete(t); + resolve(); + }; + a.waiters.add(wake); + b.waiters.add(wake); + blocked.set(t, wake); + if (a.poll() || b.poll()) wake(); + })); + } +} + +function nativePath(t: JThread, address: bigint): string { + const bytes = t.vm.mem.u8; + let end = Number(address); + const start = end; + while (bytes[end] !== 0) end++; + return normalize(new TextDecoder().decode(bytes.subarray(start, end))); +} + // Takes stream bytes: into(cap) moves up to cap bytes and returns the count. function* recv(t: JThread, s: Sock, len: number, into: (cap: number) => number): NativeGen { if (len === 0) return 0; @@ -434,6 +516,67 @@ const DC = "sun/nio/ch/DatagramChannelImpl."; const NSA = "sun/nio/ch/NativeSocketAddress."; export const natives: Natives = { + "sun/nio/fs/LinuxWatchService.eventSize()I": () => 16, + "sun/nio/fs/LinuxWatchService.eventOffsets()[I": (t) => { + const a = t.vm.newArray(t.vm.primClass("I"), 5); + (a.a as Int32Array).set([0, 4, 8, 12, 16]); + return a; + }, + "sun/nio/fs/LinuxWatchService.inotifyInit()I": (t) => initInotify(t), + "sun/nio/fs/LinuxWatchService.inotifyAddWatch(IJI)I": (t, [fd, address, mask]) => { + const state = inotify(t); + if (!state || state.sock.fd !== fd || !socksOf(t.vm.state).has(fd)) throw ioError(EBADF); + const path = nativePath(t, address); + if (!t.vm.fs.stat(path)?.dir) throw ioError(ENOENT); + let wd = state.paths.get(path); + if (wd === undefined) { + wd = state.nextWd++; + state.paths.set(path, wd); + } + state.watches.set(wd, { path, mask: mask & IN_MASK_ADD }); + return wd; + }, + "sun/nio/fs/LinuxWatchService.inotifyRmWatch(II)V": (t, [fd, wd]) => { + const state = inotify(t); + if (!state || state.sock.fd !== fd) throw ioError(EBADF); + const watch = state.watches.get(wd); + if (watch) { + state.watches.delete(wd); + state.paths.delete(watch.path); + } + }, + "sun/nio/fs/LinuxWatchService.configureBlocking(IZ)V": (t, [fd, blocking]) => { + const s = socksOf(t.vm.state).get(fd); + if (!s) throw ioError(EBADF); + s.blocking = blocking === 1; + }, + "sun/nio/fs/LinuxWatchService.socketpair([I)V": (t, [sv]) => { + const [r, w] = newPipe(); + r.blocking = w.blocking = false; + sv.a[0] = register(t, r); + sv.a[1] = register(t, w); + }, + "sun/nio/fs/LinuxWatchService.poll(II)I": function* (t, [fd1, fd2]): NativeGen { + const a = socksOf(t.vm.state).get(fd1), b = socksOf(t.vm.state).get(fd2); + if (!a || !b) throw ioError(EBADF); + return yield* pollPair(t, a, b); + }, + "sun/nio/fs/UnixNativeDispatcher.read0(IJI)I": function* (t, [fd, address, len]): NativeGen { + const s = socksOf(t.vm.state).get(fd); + if (!s) throw new PendingThrow("sun/nio/fs/UnixException", null, { desc: "(I)V", args: [EBADF] }); + const n = yield* recv(t, s, len, (count) => s.take(t.vm.mem.u8, Number(address), count)); + if (n === IOS_UNAVAILABLE) throw new PendingThrow("sun/nio/fs/UnixException", null, { desc: "(I)V", args: [11] }); + return n; + }, + "sun/nio/fs/UnixNativeDispatcher.write0(IJI)I": function* (t, [fd, address, len]): NativeGen { + const s = socksOf(t.vm.state).get(fd); + if (!s) throw new PendingThrow("sun/nio/fs/UnixException", null, { desc: "(I)V", args: [EBADF] }); + return yield* xmit(t, s, t.vm.mem.u8.slice(Number(address), Number(address) + len)); + }, + "sun/nio/fs/UnixNativeDispatcher.close0(I)V": (t, [fd]) => { + if (inotify(t)?.sock.fd === fd) inotify(t)!.remove(); + closeFd(t, fd); + }, [`${N}isIPv6Available0()Z`]: () => 0, [`${N}isReusePortAvailable0()Z`]: () => 1, [`${N}isExclusiveBindAvailable()I`]: () => -1, diff --git a/test/java/WatchService.java b/test/java/WatchService.java new file mode 100644 index 0000000..e7a8570 --- /dev/null +++ b/test/java/WatchService.java @@ -0,0 +1,35 @@ +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchKey; +import java.util.TreeSet; + +public class WatchService { + private static void collect(java.nio.file.WatchService watch, TreeSet events) throws Exception { + WatchKey key; + while ((key = watch.poll()) != null) { + for (var event : key.pollEvents()) { + if (event.kind() != StandardWatchEventKinds.OVERFLOW) + events.add(event.kind().name() + " " + event.context()); + } + key.reset(); + } + } + + public static void main(String[] args) throws Exception { + Path dir = Files.createTempDirectory("watch"); + Path file = dir.resolve("item"); + TreeSet events = new TreeSet<>(); + try (java.nio.file.WatchService watch = dir.getFileSystem().newWatchService()) { + dir.register(watch, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + Files.writeString(file, "first"); + Files.writeString(file, "second"); + Files.delete(file); + Thread.sleep(100); + collect(watch, events); + } + for (String event : events) System.out.println(event); + Files.delete(dir); + } +} From 310519f6571a53a9a5bc95b327ff099c332e50f6 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:34:07 -0600 Subject: [PATCH 02/12] Poll watch descriptors for readable events --- src/natives/sockets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 768e756..917994d 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -197,7 +197,7 @@ function* pollSock(t: JThread, s: Sock, events: number, timeout: number): Native function* pollPair(t: JThread, a: Sock, b: Sock): NativeGen { for (;;) { - const ready = (a.poll() ? 1 : 0) + (b.poll() ? 1 : 0); + const ready = ((a.poll() & POLLIN) ? 1 : 0) + ((b.poll() & POLLIN) ? 1 : 0); if (ready) return ready; yield t.block(new Promise((resolve) => { let done = false; @@ -212,7 +212,7 @@ function* pollPair(t: JThread, a: Sock, b: Sock): NativeGen { a.waiters.add(wake); b.waiters.add(wake); blocked.set(t, wake); - if (a.poll() || b.poll()) wake(); + if ((a.poll() & POLLIN) || (b.poll() & POLLIN)) wake(); })); } } From bf2d6d9c442b7991875a3c87d2fd696e1a493f31 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:38:36 -0600 Subject: [PATCH 03/12] Support separate watch service instances --- src/natives/sockets.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 917994d..26f4be4 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -25,13 +25,13 @@ const IN_CREATE = 0x100, IN_DELETE = 0x200, IN_Q_OVERFLOW = 0x4000; const IN_MASK_ADD = IN_MODIFY | IN_ATTRIB | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE | IN_DELETE; const MAX_INOTIFY_BYTES = 1 << 20; -function inotify(t: JThread): Inotify | undefined { - return t.vm.state.get("inotify") as Inotify | undefined; +function inotify(t: JThread, fd: number): Inotify | undefined { + return (t.vm.state.get("inotify") as Map | undefined)?.get(fd); } function initInotify(t: JThread): number { - const old = inotify(t); - if (old) return old.sock.fd; + let all = t.vm.state.get("inotify") as Map | undefined; + if (!all) t.vm.state.set("inotify", (all = new Map())); const sock = new Sock("stream"); sock.connected = true; sock.blocking = false; @@ -64,8 +64,7 @@ function initInotify(t: JThread): number { } }); const fd = register(t, sock); - sock.impl = state; - t.vm.state.set("inotify", state); + all.set(fd, state); t.vm.exited.then(() => state.remove()); return fd; } @@ -524,8 +523,8 @@ export const natives: Natives = { }, "sun/nio/fs/LinuxWatchService.inotifyInit()I": (t) => initInotify(t), "sun/nio/fs/LinuxWatchService.inotifyAddWatch(IJI)I": (t, [fd, address, mask]) => { - const state = inotify(t); - if (!state || state.sock.fd !== fd || !socksOf(t.vm.state).has(fd)) throw ioError(EBADF); + const state = inotify(t, fd); + if (!state || !socksOf(t.vm.state).has(fd)) throw ioError(EBADF); const path = nativePath(t, address); if (!t.vm.fs.stat(path)?.dir) throw ioError(ENOENT); let wd = state.paths.get(path); @@ -537,8 +536,8 @@ export const natives: Natives = { return wd; }, "sun/nio/fs/LinuxWatchService.inotifyRmWatch(II)V": (t, [fd, wd]) => { - const state = inotify(t); - if (!state || state.sock.fd !== fd) throw ioError(EBADF); + const state = inotify(t, fd); + if (!state) throw ioError(EBADF); const watch = state.watches.get(wd); if (watch) { state.watches.delete(wd); @@ -574,7 +573,9 @@ export const natives: Natives = { return yield* xmit(t, s, t.vm.mem.u8.slice(Number(address), Number(address) + len)); }, "sun/nio/fs/UnixNativeDispatcher.close0(I)V": (t, [fd]) => { - if (inotify(t)?.sock.fd === fd) inotify(t)!.remove(); + const state = inotify(t, fd); + state?.remove(); + (t.vm.state.get("inotify") as Map | undefined)?.delete(fd); closeFd(t, fd); }, [`${N}isIPv6Available0()Z`]: () => 0, From a788e51ed712f33f1e2f4797d8862bf97a53f04f Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:44:36 -0600 Subject: [PATCH 04/12] Invalidate watches when their directories are removed --- src/natives/sockets.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 26f4be4..7ce7808 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -22,6 +22,7 @@ const EEXIST = 17, EBADF = 9, EPROTO = 71; type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; remove: () => void }; const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; const IN_CREATE = 0x100, IN_DELETE = 0x200, IN_Q_OVERFLOW = 0x4000; +const IN_IGNORED = 0x8000; const IN_MASK_ADD = IN_MODIFY | IN_ATTRIB | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE | IN_DELETE; const MAX_INOTIFY_BYTES = 1 << 20; @@ -41,6 +42,16 @@ function initInotify(t: JThread): number { const parent = slash === 0 ? "/" : path.slice(0, slash); const name = new TextEncoder().encode(path.slice(slash + 1)); for (const [wd, watch] of state.watches) { + if (event === "delete" && watch.path === path) { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setInt32(0, wd, true); + view.setUint32(4, IN_IGNORED, true); + sock.push(bytes); + state.watches.delete(wd); + state.paths.delete(path); + continue; + } if (watch.path !== parent) continue; const mask = event === "create" ? IN_CREATE : event === "delete" ? IN_DELETE : IN_MODIFY; if (!(watch.mask & mask)) continue; From a45d3ee4fb2af50bf7acc62be5b4da11221a127a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:31:11 -0600 Subject: [PATCH 05/12] Resolve relative paths for inotify watches --- src/natives/sockets.ts | 3 +-- test/java/WatchService.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 7ce7808..f80f2d4 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -13,7 +13,6 @@ import type { Datagram, Endpoint } from "../sockets.ts"; import { closeFd, newFd, openFile, resolvePath } from "./io.ts"; import { inet4, inetBytes, socketAddress } from "./net.ts"; import type { Natives } from "./util.ts"; -import { normalize } from "../fs.ts"; const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; @@ -232,7 +231,7 @@ function nativePath(t: JThread, address: bigint): string { let end = Number(address); const start = end; while (bytes[end] !== 0) end++; - return normalize(new TextDecoder().decode(bytes.subarray(start, end))); + return resolvePath(t, new TextDecoder().decode(bytes.subarray(start, end))); } // Takes stream bytes: into(cap) moves up to cap bytes and returns the count. diff --git a/test/java/WatchService.java b/test/java/WatchService.java index e7a8570..63ec522 100644 --- a/test/java/WatchService.java +++ b/test/java/WatchService.java @@ -17,7 +17,7 @@ private static void collect(java.nio.file.WatchService watch, TreeSet ev } public static void main(String[] args) throws Exception { - Path dir = Files.createTempDirectory("watch"); + Path dir = Files.createTempDirectory(Path.of("."), "watch"); Path file = dir.resolve("item"); TreeSet events = new TreeSet<>(); try (java.nio.file.WatchService watch = dir.getFileSystem().newWatchService()) { From 26bbe6ff49f0f4198edffc07ce624a7e4daa989e Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:40:45 -0600 Subject: [PATCH 06/12] Enable passing OpenJDK WatchService tests --- test/jtreg/passing.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index 3b6155c..7e83e69 100644 --- a/test/jtreg/passing.txt +++ b/test/jtreg/passing.txt @@ -2126,6 +2126,14 @@ java/nio/file/Path/PathOps.java java/nio/file/Path/ToRealPath.java java/nio/file/Path/UriImportExport.java java/nio/file/PathMatcher/Basic.java +java/nio/file/WatchService/Basic.java +java/nio/file/WatchService/LotsOfCancels.java +java/nio/file/WatchService/LotsOfCloses.java +java/nio/file/WatchService/LotsOfEntries.java +java/nio/file/WatchService/LotsOfEvents.java +java/nio/file/WatchService/MayFlies.java +java/nio/file/WatchService/SensitivityModifier.java +java/nio/file/WatchService/UpdateInterference.java java/nio/file/attribute/AclEntry/EmptySet.java java/nio/file/attribute/AclFileAttributeView/Basic.java java/nio/file/attribute/BasicFileAttributeView/Basic.java From 40d3a64524532b4a1de0fbdd0bfea77f9b1953fe Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:11:42 -0600 Subject: [PATCH 07/12] Keep one close0 native and document what WatchService sees --- docs/support.md | 3 +++ src/natives/nio.ts | 7 +++++-- src/natives/sockets.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/support.md b/docs/support.md index 554e651..598c000 100644 --- a/docs/support.md +++ b/docs/support.md @@ -12,6 +12,9 @@ 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. +- `WatchService` reports files the program itself, or another thread in the + same VM, creates, changes or deletes. It does not see changes made outside + the VM, by other programs on the host. - Not supported: GUI apps (AWT and Swing windows), `ConvolveOp`, child processes, and native libraries other than JS natives. diff --git a/src/natives/nio.ts b/src/natives/nio.ts index f359835..de8b8f9 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -4,7 +4,7 @@ import type { JObject } from "../runtime.ts"; import type { Stat } from "../fs.ts"; import { preCloseSock } from "../sockets.ts"; -import { sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; +import { closeInotify, sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; import { closeFd, endsInDot, fileInode, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; import type { OpenFile } from "./io.ts"; @@ -164,7 +164,10 @@ export const natives: Natives = { throw e; } }, - [`${N}close0(I)V`]: (t, [fd]) => closeFd(t, fd), + [`${N}close0(I)V`]: (t, [fd]) => { + closeInotify(t, fd); + closeFd(t, fd); + }, [`${N}dup(I)I`]: () => unixError(ENOSYS), [`${N}mkdir0(JI)V`]: (t, [p]) => { const abs = path(t, p); diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index f80f2d4..81012c6 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -79,6 +79,12 @@ function initInotify(t: JThread): number { return fd; } +// Stops an inotify descriptor's watches before the descriptor closes. +export function closeInotify(t: JThread, fd: number) { + inotify(t, fd)?.remove(); + (t.vm.state.get("inotify") as Map | undefined)?.delete(fd); +} + // the JDK thread token for signalling a thread out of a blocking call const blocked = new WeakMap void>(); @@ -582,12 +588,6 @@ export const natives: Natives = { if (!s) throw new PendingThrow("sun/nio/fs/UnixException", null, { desc: "(I)V", args: [EBADF] }); return yield* xmit(t, s, t.vm.mem.u8.slice(Number(address), Number(address) + len)); }, - "sun/nio/fs/UnixNativeDispatcher.close0(I)V": (t, [fd]) => { - const state = inotify(t, fd); - state?.remove(); - (t.vm.state.get("inotify") as Map | undefined)?.delete(fd); - closeFd(t, fd); - }, [`${N}isIPv6Available0()Z`]: () => 0, [`${N}isReusePortAvailable0()Z`]: () => 1, [`${N}isExclusiveBindAvailable()I`]: () => -1, From 063eb65d5697676ab8d377b8004868582367c3f0 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:20:44 -0600 Subject: [PATCH 08/12] Name the supported inotify events apart from the IN_MASK_ADD flag --- src/natives/sockets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 81012c6..ffcb3d1 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -22,7 +22,7 @@ type Inotify = { sock: Sock; watches: Map { From 0f128c5b0d490274f1497dfc5e681a2fffcd9c40 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:26:01 -0600 Subject: [PATCH 09/12] Report ENOTDIR for watches on files and bound the watch path read --- src/natives/sockets.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index ffcb3d1..b9dc8c5 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -16,7 +16,7 @@ import type { Natives } from "./util.ts"; const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; -const EEXIST = 17, EBADF = 9, EPROTO = 71; +const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20; type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; remove: () => void }; const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; @@ -234,9 +234,9 @@ function* pollPair(t: JThread, a: Sock, b: Sock): NativeGen { function nativePath(t: JThread, address: bigint): string { const bytes = t.vm.mem.u8; - let end = Number(address); - const start = end; - while (bytes[end] !== 0) end++; + const start = Number(address); + const end = bytes.indexOf(0, start); + if (end < 0) throw ioError(EINVAL); return resolvePath(t, new TextDecoder().decode(bytes.subarray(start, end))); } @@ -542,7 +542,9 @@ export const natives: Natives = { const state = inotify(t, fd); if (!state || !socksOf(t.vm.state).has(fd)) throw ioError(EBADF); const path = nativePath(t, address); - if (!t.vm.fs.stat(path)?.dir) throw ioError(ENOENT); + const st = t.vm.fs.stat(path); + if (!st) throw ioError(ENOENT); + if (!st.dir) throw ioError(ENOTDIR); let wd = state.paths.get(path); if (wd === undefined) { wd = state.nextWd++; From a69b9890f30cb78348bb9db7c7f69ec649758fa8 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:34:28 -0600 Subject: [PATCH 10/12] Keep queued inotify events on overflow, as Linux does --- src/natives/sockets.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index b9dc8c5..b6751c4 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -18,7 +18,7 @@ const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20; -type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; remove: () => void }; +type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; overflowed: boolean; remove: () => void }; const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; const IN_CREATE = 0x100, IN_DELETE = 0x200, IN_Q_OVERFLOW = 0x4000; const IN_IGNORED = 0x8000; @@ -35,7 +35,7 @@ function initInotify(t: JThread): number { const sock = new Sock("stream"); sock.connected = true; sock.blocking = false; - const state: Inotify = { sock, watches: new Map(), paths: new Map(), nextWd: 1, remove: () => {} }; + const state: Inotify = { sock, watches: new Map(), paths: new Map(), nextWd: 1, overflowed: false, remove: () => {} }; state.remove = t.vm.fs.watchChanges((path, event) => { const slash = path.lastIndexOf("/"); const parent = slash === 0 ? "/" : path.slice(0, slash); @@ -62,14 +62,17 @@ function initInotify(t: JThread): number { view.setUint32(8, 0, true); view.setUint32(12, len, true); bytes.set(name, 16); + // as Linux: a full queue keeps its events, gains one overflow record and drops new ones if (sock.rxBytes + bytes.length > MAX_INOTIFY_BYTES) { - sock.rx = []; - sock.rxBytes = 0; - const overflow = new Uint8Array(16); - new DataView(overflow.buffer).setUint32(4, IN_Q_OVERFLOW, true); - sock.push(overflow); - break; + if (!state.overflowed) { + const overflow = new Uint8Array(16); + new DataView(overflow.buffer).setUint32(4, IN_Q_OVERFLOW, true); + sock.push(overflow); + state.overflowed = true; + } + continue; } + state.overflowed = false; sock.push(bytes); } }); From aaa1088138464102227a34338d10d05602ae93dc Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:38:17 -0600 Subject: [PATCH 11/12] Report EFAULT for a watch path address outside VM memory --- src/natives/sockets.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index b6751c4..44cd703 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -16,7 +16,7 @@ import type { Natives } from "./util.ts"; const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; -const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20; +const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20, EFAULT = 14; type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; overflowed: boolean; remove: () => void }; const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; @@ -238,6 +238,7 @@ function* pollPair(t: JThread, a: Sock, b: Sock): NativeGen { function nativePath(t: JThread, address: bigint): string { const bytes = t.vm.mem.u8; const start = Number(address); + if (start <= 0 || start >= bytes.length) throw ioError(EFAULT); const end = bytes.indexOf(0, start); if (end < 0) throw ioError(EINVAL); return resolvePath(t, new TextDecoder().decode(bytes.subarray(start, end))); From 78bd09a31ded9e4d0aa196e48db0ae006e6c6137 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:39:22 -0600 Subject: [PATCH 12/12] Cap the watch path read at PATH_MAX --- src/natives/sockets.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/natives/sockets.ts b/src/natives/sockets.ts index 44cd703..beb33a7 100644 --- a/src/natives/sockets.ts +++ b/src/natives/sockets.ts @@ -16,7 +16,7 @@ import type { Natives } from "./util.ts"; const FD = "java/io/FileDescriptor"; const IOS_EOF = -1, IOS_UNAVAILABLE = -2, IOS_INTERRUPTED = -3; -const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20, EFAULT = 14; +const EEXIST = 17, EBADF = 9, EPROTO = 71, ENOTDIR = 20, EFAULT = 14, ENAMETOOLONG = 36; type Inotify = { sock: Sock; watches: Map; paths: Map; nextWd: number; overflowed: boolean; remove: () => void }; const IN_MODIFY = 0x2, IN_ATTRIB = 0x4, IN_MOVED_FROM = 0x40, IN_MOVED_TO = 0x80; @@ -239,9 +239,9 @@ function nativePath(t: JThread, address: bigint): string { const bytes = t.vm.mem.u8; const start = Number(address); if (start <= 0 || start >= bytes.length) throw ioError(EFAULT); - const end = bytes.indexOf(0, start); - if (end < 0) throw ioError(EINVAL); - return resolvePath(t, new TextDecoder().decode(bytes.subarray(start, end))); + const len = bytes.subarray(start, start + 4096).indexOf(0); + if (len < 0) throw ioError(ENAMETOOLONG); + return resolvePath(t, new TextDecoder().decode(bytes.subarray(start, start + len))); } // Takes stream bytes: into(cap) moves up to cap bytes and returns the count.