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.
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 to0, 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
symlinkerror.Node behavior
Local Node probe on v25.9.0:
Observed shape:
Perry evidence from
origin/maincrates/perry-runtime/src/fs/mod.rs::js_fs_symlink_sync()returns only a success/failure sentinel:crates/perry-runtime/src/fs/callbacks.rs::js_fs_symlink_callback()ignores that status and always calls success:crates/perry-runtime/src/node_submodules/fs_promises.rs::thunk_fs_promises_symlink()also ignores the status and always resolves: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 danglingtargetvalues, matching Node.fs.symlink(target, path[, type], cb)should pass that error to the callback.fs.promises.symlink(target, path[, type])should reject with that error.code,syscall: "symlink",path, anddest.Suggested test surface
Add deterministic parity tests for:
ENOENTacross all three forms;EEXISTacross all three forms;readlink.Scope / non-goals
readlinkerrors (node:fs: report readlink errors instead of empty targets #2733), or recursivecpsymlink semantics (node:fs: complete cp/cpSync advanced copy semantics #2516).code,syscall,path, anddestfields.Duplicate check
Searched issues and PRs for:
fs symlink EEXIST ENOENTfs.promises.symlinksymlinkSyncfs symlink errorRelated but not duplicate: #2516 covers
cp/cpSyncadvanced copy and symlink semantics, and #2738 covers hard-link creation errors.