Skip to content

Commit 6f731c8

Browse files
ssalbdivadclaude
andcommitted
fix(plugin): repoint the stable plugin link with unlinkSync, which Node 25.0-25.3's rmSync cannot do
ensureSymlink removed the old link with `rmSync(link, { force: true })`. On Node 23.0-24.13.0 and 25.0-25.3.x, rmSync's C++ port checks the path with std::filesystem::status(), which FOLLOWS symlinks (nodejs/node#61040, merged 2025-12-23, shipped in 24.13.1 and 25.4.0; 22.x was never affected). So on this machine's Node v25.2.1: - a link to a directory throws ERR_FS_EISDIR, so the primitive could never repoint the stable worker-plugin path — the case the whole stable-path design rests on (a live worker's reload observing a new build); - a DANGLING link (its build cleaned away) is reported missing, `force` swallows that, the link stays, and the symlinkSync after it throws EEXIST. Scope: ensureSymlink / stageStablePluginDir have NO production caller yet — 4857ee8 added the module as "not yet wired into either launcher" and that is still true. So no shipped Frizz ever hit this; it is a bug in the primitive that would have shipped the day it is wired in, on any Node the engines field (`^22.13.0 || >=23.4.0`) admits in the affected ranges. Not WSL-specific, as first suspected. Measured 2026-10-02 with the same probe on every Node installed here: v20.20.0, v22.18.0 and v24.14.1 remove the link; v25.2.1 throws EISDIR, and leaves a dangling link in place silently. lstat has already proved the path is a symlink, so unlinkSync is exact: it removes the link on every Node and every platform (libuv's unlink handles Windows directory links and junctions). Tests: the two failing stable-plugin-path tests pass again; a new one pins the dangling-link repoint. Negative control: restoring rmSync fails three tests (two EISDIR, the new one EEXIST). The fixed file passes 10/10 on Node 22.18.0, 24.14.1 and 25.2.1. project-root.test.ts "home reached through a symlink" failed for the same reason, but only in its finally block, after every assertion had passed — and the throw skipped removing the temp home too, so each run leaked a link and a directory into /tmp. Its cleanup now unlinks the link. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5f3adff commit 6f731c8

3 files changed

Lines changed: 31 additions & 5 deletions

File tree

‎packages/server/src/project-root.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict"
2-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { test } from "node:test"
@@ -233,7 +233,9 @@ test("the home directory is recognised even when it is reached through a symlink
233233
mkdirSync(child, { recursive: true })
234234
assert.equal(isHomeDirectory(child, real), false, "a directory inside home is not home")
235235
} finally {
236-
rmSync(link, { force: true })
236+
// unlinkSync, not rmSync: Node 25's rmSync follows a link to a directory and throws ERR_FS_EISDIR
237+
// (nodejs/node#61040), which failed this test in its cleanup after every assertion had passed.
238+
unlinkSync(link)
237239
rmSync(real, { recursive: true, force: true })
238240
}
239241
})

‎packages/server/src/stable-plugin-path.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict"
22
import { test } from "node:test"
3-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"
3+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
44
import { tmpdir } from "node:os"
55
import { isAbsolute, join } from "node:path"
66
import { defaultPluginStageRoot, defaultStablePluginRoot, ensureSymlink, stageStablePluginDir } from "./stable-plugin-path.ts"
@@ -41,6 +41,23 @@ test("ensureSymlink repoints a link aimed elsewhere — the case the whole desig
4141
assert.equal(readlinkSync(link), b, "a live worker following this path now sees buildB")
4242
})
4343

44+
// The link outlives what it pointed at whenever an old build is cleaned away. Repointing must still
45+
// work: Node 25's rmSync follows the link, finds nothing, and `force` swallows that — leaving the
46+
// dangling link in place, so recreating it failed EEXIST.
47+
test("ensureSymlink repoints a DANGLING link whose old target is gone", () => {
48+
const dir = tmp("frizz-symlink-")
49+
const gone = join(dir, "buildGone")
50+
const b = join(dir, "buildB")
51+
mkdirSync(gone); mkdirSync(b)
52+
const link = join(dir, "current")
53+
54+
ensureSymlink(link, gone)
55+
rmSync(gone, { recursive: true })
56+
assert.ok(lstatSync(link).isSymbolicLink() && !existsSync(link), "the fixture link dangles")
57+
assert.equal(ensureSymlink(link, b), "repointed")
58+
assert.equal(readlinkSync(link), b)
59+
})
60+
4461
// A RELATIVE existing link resolves against the link's own directory, never the process cwd. Compare
4562
// them naively and every call looks stale, so the link is deleted and recreated forever.
4663
test("ensureSymlink resolves an existing RELATIVE link against its own directory", () => {

‎packages/server/src/stable-plugin-path.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cpSync, lstatSync, mkdirSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs"
1+
import { cpSync, lstatSync, mkdirSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync } from "node:fs"
22
import { homedir } from "node:os"
33
import { dirname, isAbsolute, join, resolve } from "node:path"
44
import { frizzPaths } from "./frizz-paths.ts"
@@ -67,7 +67,14 @@ export function ensureSymlink(link: string, target: string): SymlinkOutcome {
6767
}
6868
const current = readlinkSync(link)
6969
if ((isAbsolute(current) ? current : resolve(dirname(link), current)) === target) return "unchanged"
70-
rmSync(link, { force: true })
70+
// unlinkSync, never rmSync: lstat above proved this is a link, and unlink removes the LINK on every
71+
// Node. rmSync looks through it on Node 23.0–24.13.0 and 25.0–25.3.x (its C++ port checked the path
72+
// with std::filesystem::status(), which follows symlinks; nodejs/node#61040 fixed it in 24.13.1 and
73+
// 25.4.0; 22.x was never affected) — a link to a directory throws
74+
// ERR_FS_EISDIR, and a DANGLING link (its build pruned) is reported missing, swallowed by `force`, and
75+
// left in place, so the symlinkSync below fails EEXIST. Measured on this machine 2026-10-02: v25.2.1
76+
// throws, v20.20.0 / v22.18.0 / v24.14.1 remove the link.
77+
unlinkSync(link)
7178
symlinkSync(target, link)
7279
return "repointed"
7380
}

0 commit comments

Comments
 (0)