Skip to content

node:fs: propagate hard-link errors across sync, callback, and promise APIs #2738

@andrewtdiz

Description

@andrewtdiz

Summary

Node reports hard-link syscall failures through fs.linkSync, callback fs.link, and fs.promises.link. Perry's current link helper reduces std::fs::hard_link failures to 0, while callback and promise wrappers ignore that status. Failed hard-link creation can therefore look like a successful no-op.

Common cases include missing source files, missing destination parents, and existing destination paths.

Node behavior

Local Node probe on v25.9.0:

const fs = require("node:fs");
const fsp = require("node:fs/promises");

fs.linkSync("missing-src.txt", "dest.txt");
// throws Error ENOENT, syscall "link", path oldPath, dest newPath

fs.linkSync("src.txt", "missing-parent/dest.txt");
// throws Error ENOENT, syscall "link", path oldPath, dest newPath

fs.linkSync("src.txt", "existing.txt");
// throws Error EEXIST, syscall "link", path oldPath, dest newPath

fs.link("src.txt", "existing.txt", (err) => {
  console.log(err.code, err.syscall, err.path, err.dest);
});

await fsp.link("src.txt", "existing.txt");
// rejects with the same EEXIST / link / path / dest shape

Observed shape:

missing source sync: Error ENOENT syscall=link path=<old> dest=<new>
missing destination parent sync: Error ENOENT syscall=link path=<old> dest=<new>
existing destination sync: Error EEXIST syscall=link path=<old> dest=<new>
callback and promises report/reject with the same fields

Perry evidence from origin/main

crates/perry-runtime/src/fs/mod.rs::js_fs_link_sync() returns only a success/failure sentinel:

if fs::hard_link(from, to).is_ok() {
    1
} else {
    0
}

crates/perry-runtime/src/fs/callbacks.rs::js_fs_link_callback() preflights only the source path, then ignores the actual hard-link result and always calls success:

if let Some(err_val) = fs_callback_lstat_error(from_value, "link") {
    call_cb_err1(cb, err_val);
    return undefined;
}
let _ = js_fs_link_sync(from_value, to_value);
call_cb0(cb);

That misses destination-side failures such as EEXIST and missing destination parents.

crates/perry-runtime/src/node_submodules/fs_promises.rs::thunk_fs_promises_link() also discards the status and always resolves:

let _ = crate::fs::js_fs_link_sync(from, to);
promise_undefined()

The sync path uses the same status-returning helper, so syscall failures have no typed error value to throw.

Expected compatibility

  • fs.linkSync(existingPath, newPath) should throw a Node-shaped fs error when hard-link creation fails.
  • fs.link(existingPath, newPath, cb) should pass that error to the callback.
  • fs.promises.link(existingPath, newPath) should reject with that error.
  • Error shape should include at least code, syscall: "link", path, and dest.

Suggested test surface

Add deterministic parity tests for:

  • missing source across sync/callback/promise APIs;
  • existing source plus missing destination parent across sync/callback/promise APIs;
  • existing destination path across sync/callback/promise APIs;
  • successful hard link creates a second path to the same file and resolves/callbacks with undefined.

Scope / non-goals

Duplicate check

Searched issues and PRs for:

  • fs link EEXIST ENOENT
  • fs.promises.link
  • linkSync hard link
  • fs link error

Broad fs link error search only returned unrelated/noisy hits and other fs issues; no hard-link error-propagation issue or PR appeared.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions