diff --git a/docs/support.md b/docs/support.md index a885e3f..554e651 100644 --- a/docs/support.md +++ b/docs/support.md @@ -19,7 +19,8 @@ java-web targets headless Java: console apps, libraries and javac. Locales other than English need `cheerpjInit({ localeData: true })`, or `--locale-data` under Node (`localeData: true` for `runNode`). Adding - `jdk.localedata` or `ALL-SYSTEM` with `--add-modules` turns it on too. Turn it on + `jdk.localedata` or `ALL-SYSTEM` with `--add-modules` turns it on too, and a + `--limit-modules` list that leaves `jdk.localedata` out turns it off. Turn it on when a program formats numbers, dates or currencies, or prints month and day names, for a locale other than English: for example `String.format(Locale.GERMANY, ...)`, or a German default locale. Without it, diff --git a/src/fs.ts b/src/fs.ts index 9f65408..ba90da9 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -4,6 +4,9 @@ export interface Stat { dir: boolean; size: number; mtime: number; + // the modification time in nanoseconds, when it was set with more precision + // than mtime keeps + mtimeNs?: bigint; // permission and special bits, when the backend can report them mode?: number; // a character device; it streams and is never read whole @@ -54,6 +57,8 @@ export function normalize(path: string): string { } export class VFS { + // path -> the time a VM set, and the mtime the backend reported right after + private setTimes = new Map(); private mounts: { prefix: string; backend: Backend }[] = []; private hidden: string[] = []; @@ -113,7 +118,12 @@ export class VFS { if (this.hidden.length && this.isHidden(n)) return null; const r = this.resolve(path); const s = r ? r[0].stat(r[1]) : null; - if (s) return s; + if (s) { + const set = this.setTimes.get(n); + if (set?.ms === s.mtime) return { ...s, mtimeNs: set.ns }; + if (set) this.setTimes.delete(n); + return s; + } if (normalize(path) === "/" || this.mountsUnder(path).length > 0 || this.isMountPoint(path)) { return { dir: true, size: 0, mtime: 0 }; } @@ -170,13 +180,21 @@ export class VFS { remove(path: string): boolean { const r = this.resolve(path); - return !!r && r[0].writable && !!r[0].remove?.(r[1]); + if (!r || !r[0].writable || !r[0].remove?.(r[1])) return false; + this.setTimes.delete(normalize(path)); + 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) return false; - return !!a[0].rename?.(a[1], b[1]); + if (!a || !b || a[0] !== b[0] || !a[0].writable || !a[0].rename?.(a[1], b[1])) return false; + // 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 + "/"); + const moved = [...this.setTimes].filter(([p]) => under(p, src)); + for (const p of [...this.setTimes.keys()]) if (under(p, src) || under(p, dst)) this.setTimes.delete(p); + for (const [p, set] of moved) this.setTimes.set(dst + p.slice(src.length), set); + return true; } writable(path: string): boolean { @@ -184,9 +202,18 @@ export class VFS { return !!r && r[0].writable; } - setMtime(path: string, ms: number): boolean { + // ns, when given, is the exact time asked for. Hosts keep times as floating + // point seconds or milliseconds, which lose nanoseconds, so stat reports ns + // for as long as the file keeps the time this set. + setMtime(path: string, ms: number, ns?: bigint): boolean { const r = this.resolve(path); - return !!r && r[0].writable && !!r[0].setMtime?.(r[1], ms); + if (!r || !r[0].writable || !r[0].setMtime?.(r[1], ms)) return false; + 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 + if (st && Math.abs(st.mtime - ms) < 1) this.setTimes.set(n, { ms: st.mtime, ns: ns! }); + else this.setTimes.delete(n); + return true; } denied(path: string): boolean { diff --git a/src/natives/io.ts b/src/natives/io.ts index 170ee2a..6c3941f 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -195,9 +195,9 @@ export function syncPath(t: JThread, abs: string) { } // Pending writes go out first, so closing the file later does not change the time again. -export function setMtime(t: JThread, abs: string, ms: number): boolean { +export function setMtime(t: JThread, abs: string, ms: number, ns?: bigint): boolean { syncPath(t, abs); - return t.vm.fs.setMtime(abs, ms); + return t.vm.fs.setMtime(abs, ms, ns); } export function fileOf(t: JThread, fd: number): OpenFile { @@ -452,7 +452,13 @@ export const natives: Natives = { const abs = resolvePath(t, pathOf(t, f)); return !!t.vm.fs.stat(abs) && t.vm.fs.access(abs, access); }, - [`${UFS}.getLastModifiedTime0(Ljava/io/File;)J`]: (t, [, f]) => BigInt(Math.floor(t.vm.fs.stat(resolvePath(t, pathOf(t, f)))?.mtime ?? 0)), + [`${UFS}.getLastModifiedTime0(Ljava/io/File;)J`]: (t, [, f]) => { + const st = t.vm.fs.stat(resolvePath(t, pathOf(t, f))); + if (st?.mtimeNs === undefined) return BigInt(Math.floor(st?.mtime ?? 0)); + // down, as HotSpot divides a negative time + const ms = st.mtimeNs / 1_000_000n; + return st.mtimeNs < 0n && ms * 1_000_000n !== st.mtimeNs ? ms - 1n : ms; + }, [`${UFS}.getLength0(Ljava/io/File;)J`]: (t, [, f]) => { const abs = resolvePath(t, pathOf(t, f)); syncPath(t, abs); @@ -477,7 +483,7 @@ export const natives: Natives = { [`${UFS}.createDirectory0(Ljava/io/File;)Z`]: (t, [, f]) => t.vm.fs.mkdir(resolvePath(t, pathOf(t, f))), [`${UFS}.rename0(Ljava/io/File;Ljava/io/File;)Z`]: (t, [, a, b]) => renamePath(t, resolvePath(t, pathOf(t, a)), resolvePath(t, pathOf(t, b))), - [`${UFS}.setLastModifiedTime0(Ljava/io/File;J)Z`]: (t, [, f, ms]) => setMtime(t, resolvePath(t, pathOf(t, f)), Number(ms)), + [`${UFS}.setLastModifiedTime0(Ljava/io/File;J)Z`]: (t, [, f, ms]) => setMtime(t, resolvePath(t, pathOf(t, f)), Number(ms), BigInt(ms) * 1_000_000n), [`${UFS}.setReadOnly0(Ljava/io/File;)Z`]: (t, [, f]) => chmod(t, f, (m) => m & ~0o222), [`${UFS}.getSpace0(Ljava/io/File;I)J`]: () => 0n, [`${UFS}.getNameMax0(Ljava/lang/String;)J`]: () => 255n, diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 929f1a9..6e52b3b 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -82,7 +82,13 @@ function fillAttrs(t: JThread, attrs: JObject, p: string): number { function fillStat(t: JThread, attrs: JObject, st: Stat, writable: boolean, ino: number, size = st.size, nlink = 1) { const vm = t.vm, A = "sun/nio/fs/UnixFileAttributes"; const mode = st.device ? S_IFCHR | 0o666 : (st.dir ? S_IFDIR : S_IFREG) | (st.mode ?? (st.dir ? (writable ? 0o755 : 0o555) : (writable ? 0o644 : 0o444))); - const sec = BigInt(Math.floor(st.mtime / 1000)), nsec = BigInt(Math.floor((st.mtime % 1000) * 1e6)); + let sec = BigInt(Math.floor(st.mtime / 1000)), nsec = BigInt(Math.floor((st.mtime % 1000) * 1e6)); + if (st.mtimeNs !== undefined) { + sec = st.mtimeNs / 1_000_000_000n; + nsec = st.mtimeNs % 1_000_000_000n; + // before 1970: whole seconds round down, nanoseconds stay positive + if (nsec < 0n) [sec, nsec] = [sec - 1n, nsec + 1_000_000_000n]; + } vm.setField(attrs, A, "st_mode", mode); vm.setField(attrs, A, "st_ino", BigInt(ino)); vm.setField(attrs, A, "st_dev", 1n); @@ -215,11 +221,11 @@ export const natives: Natives = { [`${N}utimensat0(IJJJI)V`]: (t, [, p, , mtime]) => { const abs = path(t, p); if (!t.vm.fs.stat(abs)) unixError(ENOENT); - if (!setMtime(t, abs, Number(mtime) / 1e6)) unixError(EROFS); + if (!setMtime(t, abs, Number(mtime) / 1e6, BigInt(mtime))) unixError(EROFS); }, [`${N}futimens0(IJJ)V`]: (t, [fd, , mtime]) => { const f = fileOf(t, fd); - if (!setMtime(t, f.path, Number(mtime) / 1e6)) unixError(EROFS); + if (!setMtime(t, f.path, Number(mtime) / 1e6, BigInt(mtime))) unixError(EROFS); }, [`${N}opendir0(J)J`]: (t, [p]) => { const abs = path(t, p); diff --git a/test/java/FileTimes.java b/test/java/FileTimes.java new file mode 100644 index 0000000..2f6c796 --- /dev/null +++ b/test/java/FileTimes.java @@ -0,0 +1,38 @@ +import java.io.File; +import java.nio.file.*; +import java.nio.file.attribute.*; +import java.util.concurrent.TimeUnit; + +// A modification time set to the nanosecond reads back the same, as HotSpot +// gives it on a file system that keeps nanoseconds. +public class FileTimes { + public static void main(String[] args) throws Exception { + Path dir = Files.createTempDirectory("times"); + Path file = Files.createFile(dir.resolve("f")); + try { + long[] times = { 1762300000123456789L, 1762300000999999999L, 1000000000000000001L }; + for (long ns : times) { + FileTime t = FileTime.from(ns, TimeUnit.NANOSECONDS); + Files.setLastModifiedTime(file, t); + FileTime back = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime(); + System.out.println(ns + " " + back.equals(t) + " " + back.to(TimeUnit.NANOSECONDS)); + } + // the directory's time, as java/nio/file/attribute/BasicFileAttributeView/Basic.java copies it + FileTime dirTime = Files.readAttributes(dir, BasicFileAttributes.class).lastModifiedTime(); + Files.getFileAttributeView(file, BasicFileAttributeView.class).setTimes(dirTime, null, null); + System.out.println("dir time " + Files.getLastModifiedTime(file).equals(dirTime)); + // the exact time moves with the file + FileTime exact = FileTime.from(1762300000123456789L, TimeUnit.NANOSECONDS); + Files.setLastModifiedTime(file, exact); + Path moved = Files.move(file, dir.resolve("g")); + System.out.println("moved " + Files.getLastModifiedTime(moved).equals(exact)); + Files.move(moved, file); + File f = file.toFile(); + f.setLastModified(1762300000123L); + System.out.println("File " + f.lastModified() + " " + Files.getLastModifiedTime(file).toMillis()); + } finally { + Files.delete(file); + Files.delete(dir); + } + } +}