Skip to content

node:fs: missing files do not throw (readFileSync returns ""), errors carry no .code, and readFileSync(path) returns a string instead of a Buffer #50

Closed

Description

@hassidan

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=/tmp
fs_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, commit ea7c70c, built with cargo +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

  1. siktec-lab commented on Nov 11, 2026

    @siktec-lab
    Collaborator

    Addressed in #66 (v0.10.10):

    • node:fs and node:fs/promises now throw/reject with standard POSIX Error objects (err.code = ENOENT | EEXIST | EISDIR | ENOTDIR | ENOTEMPTY | EBADF, err.errno, err.syscall, err.path) while preserving uncatchable OCap FsReadDenied / FsWriteDenied sandbox traps.
    • fs.statSync(p, { throwIfNoEntry: false }) and fs.lstatSync(p, { throwIfNoEntry: false }) return undefined when the target does not exist.
    • fs.readFileSync(p) and await fsp.readFile(p) return a Buffer by default when no encoding is specified (and a string when an encoding is passed).
    • fs.writeFileSync / fsp.writeFile honor { flag: a | wx | ... }, utimesSync / fsp.utimes persist mtime updates, readdirSync supports { recursive: true } (including withFileTypes: true), and file-descriptor APIs (openSync, readSync, writeSync, fstatSync, ftruncateSync, closeSync, fsp.open) are implemented.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions