Repository navigation
Description
Test I ran
Fourteen node:fs calls against a scratch directory, run on Node and on Slice with read/write capability for that directory. All paths are under /tmp, i.e. inside Slice's VFS; none of the checks depend on host persistence.
node fs_semantics.ts
slice run fs_semantics.ts --allow-read=/tmp --allow-write=/tmpfs_semantics.ts
import * as fs from "node:fs";
const d = "/tmp/slice-repro-fs";
fs.rmSync(d, { recursive: true, force: true }); fs.mkdirSync(d, { recursive: true });
const show = (label: string, fn: () => unknown) => { let r; try { r = fn(); } catch (e: any) { r = "throws " + e.name + " code=" + e.code; } console.log(label + " => " + (typeof r === "string" ? r : JSON.stringify(r))); };
show("1 readFileSync(missing)", () => { const v = fs.readFileSync(d + "/missing.txt"); return "returned " + typeof v + " " + JSON.stringify(String(v)); });
show("2 readFileSync(missing,'utf8')", () => { const v = fs.readFileSync(d + "/missing.txt", "utf8"); return "returned " + typeof v + " " + JSON.stringify(v); });
show("3 statSync(missing)", () => { const s = fs.statSync(d + "/missing.txt"); return "returned isFile=" + s.isFile() + " size=" + s.size; });
show("4 statSync(missing,{throwIfNoEntry:false})", () => String(fs.statSync(d + "/missing.txt", { throwIfNoEntry: false })));
show("5 accessSync(missing)", () => { fs.accessSync(d + "/missing.txt"); return "returned"; });
show("6 mkdirSync(existing)", () => { fs.mkdirSync(d); return "returned"; });
fs.writeFileSync(d + "/bin", new Uint8Array([0, 255, 128, 10]));
show("7 readFileSync(bin) no encoding", () => { const v = fs.readFileSync(d + "/bin"); return "isBuffer=" + Buffer.isBuffer(v) + " typeof=" + typeof v + " length=" + v.length + " bytes=" + JSON.stringify(Array.from(v as any)); });
fs.writeFileSync(d + "/bin2", Buffer.from([0, 255, 128, 10]));
show("8 Buffer write -> read hex", () => Buffer.from(fs.readFileSync(d + "/bin2") as any).toString("hex"));
fs.writeFileSync(d + "/fl.txt", "1"); fs.writeFileSync(d + "/fl.txt", "2", { flag: "a" });
show("9 writeFileSync(flag:'a')", () => fs.readFileSync(d + "/fl.txt", "utf8"));
fs.writeFileSync(d + "/t.txt", "x"); fs.utimesSync(d + "/t.txt", 1000, 2000);
show("10 utimesSync -> mtimeMs", () => fs.statSync(d + "/t.txt").mtimeMs);
fs.mkdirSync(d + "/ne/sub", { recursive: true }); fs.writeFileSync(d + "/ne/sub/x.txt", "x");
show("11 rmdirSync(non-empty)", () => { fs.rmdirSync(d + "/ne"); return "returned; still exists=" + fs.existsSync(d + "/ne/sub/x.txt"); });
fs.mkdirSync(d + "/r/sub", { recursive: true }); fs.writeFileSync(d + "/r/sub/x.txt", "x");
show("12 readdirSync(recursive)", () => (fs.readdirSync(d + "/r", { recursive: true }) as string[]).sort());
show("13 fs.openSync/writeSync/readSync", () => { const fd = fs.openSync(d + "/fd.txt", "w+"); fs.writeSync(fd, "abcdef"); const b = Buffer.alloc(3); fs.readSync(fd, b, 0, 3, 2); fs.closeSync(fd); return b.toString(); });
show("14 typeof fd fns", () => [typeof fs.openSync, typeof fs.writeSync, typeof fs.readSync, typeof fs.closeSync, typeof fs.fstatSync, typeof fs.fsyncSync]);
fs.rmSync(d, { recursive: true, force: true });Result
Node v24.22.2:
1 readFileSync(missing) => throws Error code=ENOENT
2 readFileSync(missing,'utf8') => throws Error code=ENOENT
3 statSync(missing) => throws Error code=ENOENT
4 statSync(missing,{throwIfNoEntry:false}) => undefined
5 accessSync(missing) => throws Error code=ENOENT
6 mkdirSync(existing) => throws Error code=EEXIST
7 readFileSync(bin) no encoding => isBuffer=true typeof=object length=4 bytes=[0,255,128,10]
8 Buffer write -> read hex => 00ff800a
9 writeFileSync(flag:'a') => 12
10 utimesSync -> mtimeMs => 2000000
11 rmdirSync(non-empty) => throws Error code=ENOTEMPTY
12 readdirSync(recursive) => ["sub","sub/x.txt"]
13 fs.openSync/writeSync/readSync => cde
14 typeof fd fns => ["function","function","function","function","function","function"]
Slice v0.10.7:
1 readFileSync(missing) => returned string ""
2 readFileSync(missing,'utf8') => returned string ""
3 statSync(missing) => returned isFile=true size=0
4 statSync(missing,{throwIfNoEntry:false}) => [object Object]
5 accessSync(missing) => throws Error code=undefined
6 mkdirSync(existing) => returned
7 readFileSync(bin) no encoding => isBuffer=false typeof=string length=4 bytes=["\u0000","�","�","\n"]
8 Buffer write -> read hex => 00efbfbdefbfbd0a
9 writeFileSync(flag:'a') => 2
10 utimesSync -> mtimeMs => 1794304557455
11 rmdirSync(non-empty) => returned; still exists=false
12 readdirSync(recursive) => ["sub"]
13 fs.openSync/writeSync/readSync => throws TypeError code=undefined
14 typeof fd fns => ["undefined","undefined","undefined","undefined","undefined","undefined"]
What is wrong
| # | Slice | Node / documented |
|---|---|---|
| 1, 2 | readFileSync of a missing file returns "" |
throws ENOENT |
| 3 | statSync of a missing file returns a Stats object with isFile() === true, size 0 |
throws ENOENT |
| 4 | statSync(p, { throwIfNoEntry: false }) returns a Stats object |
returns undefined (the closing note on #28 lists throwIfNoEntry as implemented) |
| 5 | accessSync error has no .code |
err.code === "ENOENT" |
| 6 | mkdirSync of an existing directory succeeds |
throws EEXIST |
| 7, 8 | readFileSync(p) with no encoding returns a string; bytes ≥ 0x80 become U+FFFD, so binary files are corrupted |
returns a Buffer (docs/general/07_…md shows const rawBuf: Buffer = fs.readFileSync("/tmp/report.json")) |
| 9 | writeFileSync(p, d, { flag: "a" }) overwrites |
appends |
| 10 | utimesSync does not change mtimeMs |
sets it (listed as added in #41) |
| 11 | rmdirSync on a non-empty directory deletes it recursively |
throws ENOTEMPTY |
| 12 | readdirSync(p, { recursive: true }) returns only the top level |
returns nested entries |
| 13, 14 | openSync / readSync / writeSync / closeSync / fstatSync / fsyncSync are undefined |
functions (not claimed in the matrix; listed for completeness) |
Rows 1–3 are the ones most likely to hurt real code: try { JSON.parse(fs.readFileSync(cfg)) } catch (e) { if (e.code === "ENOENT") … } and if (fs.statSync(p).isFile()) both take the wrong branch silently. The promise forms behave the same way (fsp.readFile(missing) resolves; it does not reject).
Likely cause
crates/slice-engine/src/bindings/fs.rs: __slice_fs_read_file_sync ends with std::fs::read(..).unwrap_or_default() and returns the empty string, and no binding constructs an error with a code. In BUILTIN_FS_SNIPPET, readFileSync only takes the Buffer path when opts === null, "buffer" or { encoding: null | "buffer" }; undefined falls through to "utf8".
Environment
- Slice
v0.10.7, commitea7c70c, built withcargo +1.96.0 build --release --locked --bin slice - Reference: Node.js
v24.22.2(same script, same machine) - Ubuntu 26.04, Linux 7.0, x86_64
- Not comparable on v0.10.5 (several of these functions did not exist there).
Activity
siktec-lab commentedon Nov 11, 2026
Addressed in #66 (
v0.10.10):node:fsandnode:fs/promisesnow throw/reject with standard POSIXErrorobjects (err.code = ENOENT | EEXIST | EISDIR | ENOTDIR | ENOTEMPTY | EBADF,err.errno,err.syscall,err.path) while preserving uncatchable OCapFsReadDenied/FsWriteDeniedsandbox traps.fs.statSync(p, { throwIfNoEntry: false })andfs.lstatSync(p, { throwIfNoEntry: false })returnundefinedwhen the target does not exist.fs.readFileSync(p)andawait fsp.readFile(p)return aBufferby default when no encoding is specified (and astringwhen an encoding is passed).fs.writeFileSync/fsp.writeFilehonor{ flag: a | wx | ... },utimesSync/fsp.utimespersistmtimeupdates,readdirSyncsupports{ recursive: true }(includingwithFileTypes: true), and file-descriptor APIs (openSync,readSync,writeSync,fstatSync,ftruncateSync,closeSync,fsp.open) are implemented.