Skip to content

node:fs: propagate symlink destination errors across sync, callback, and promise APIs #2740

@andrewtdiz

Description

@andrewtdiz

Summary

Node allows dangling symlink targets, but still reports destination-side symlink creation failures such as a missing destination parent or an existing destination path. Perry's current symlink helper reduces std::os::*::fs::symlink* failures to 0, and the callback/promise wrappers ignore that status, so failed symlink creation can look like success.

This can hide package-manager/workspace link setup failures and leave later code debugging a missing link instead of the original symlink error.

Node behavior

Local Node probe on v25.9.0:

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

fs.symlinkSync("missing-target.txt", "dangling-link.txt");
// succeeds: dangling symlinks are allowed

fs.symlinkSync("missing-target.txt", "missing-parent/link.txt");
// throws Error ENOENT, syscall "symlink", path target, dest link path

fs.symlinkSync("missing-target.txt", "existing.txt");
// throws Error EEXIST, syscall "symlink", path target, dest link path

fs.symlink("missing-target.txt", "existing.txt", (err) => {
  console.log(err.code, err.syscall, err.path, err.dest);
});

await fsp.symlink("missing-target.txt", "existing.txt");
// rejects with the same EEXIST / symlink / path / dest shape

Observed shape:

dangling target: succeeds and readlink returns the target string
missing destination parent: Error ENOENT syscall=symlink path=<target> dest=<link>
existing destination: Error EEXIST syscall=symlink path=<target> dest=<link>
callback and promises report/reject with the same fields

Perry evidence from origin/main

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

#[cfg(unix)]
{
    if std::os::unix::fs::symlink(target, path).is_ok() {
        1
    } else {
        0
    }
}
#[cfg(windows)]
{
    if std::os::windows::fs::symlink_file(target, path).is_ok() {
        1
    } else {
        0
    }
}

crates/perry-runtime/src/fs/callbacks.rs::js_fs_symlink_callback() ignores that status and always calls success:

let _ = js_fs_symlink_sync(from_value, to_value);
call_cb0(last_callback(&[arg2, arg3]));

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

let _ = crate::fs::js_fs_symlink_sync(target, path);
promise_undefined()

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

Expected compatibility

  • fs.symlinkSync(target, path[, type]) should still allow dangling target values, matching Node.
  • It should throw a Node-shaped fs error when creating the link path fails.
  • fs.symlink(target, path[, type], cb) should pass that error to the callback.
  • fs.promises.symlink(target, path[, type]) should reject with that error.
  • Error shape should include at least code, syscall: "symlink", path, and dest.

Suggested test surface

Add deterministic parity tests for:

  • dangling target succeeds across sync/callback/promise APIs;
  • missing destination parent reports ENOENT across all three forms;
  • existing destination reports EEXIST across all three forms;
  • successful symlink can be read back with readlink.

Scope / non-goals

Duplicate check

Searched issues and PRs for:

  • fs symlink EEXIST ENOENT
  • fs.promises.symlink
  • symlinkSync
  • fs symlink error

Related but not duplicate: #2516 covers cp / cpSync advanced copy and symlink semantics, and #2738 covers hard-link creation errors.

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