fix(boot): unlink stale profile fallback links instead of rmSync

This commit is contained in:
Huanqi Cao
2026-08-13 15:16:11 +08:00
committed by Chinesezjc
parent 4ed036da1c
commit 9e9073da24
4 changed files with 60 additions and 2 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md
2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b
2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6
@@ -0,0 +1,25 @@
# Agent Note: Unlink stale profile fallback links instead of rmSync
Status: implemented
English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md)
## Problem
`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call.
## Decision
`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive.
## Alternatives considered
**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard.
**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom.
**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window.
## Consequences
Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation.
@@ -0,0 +1,25 @@
# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync
Status: implemented
[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文
## 问题
`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive``rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。
## 决策
`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。
## 考虑过的替代方案
**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标,但 `recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。
**`rmdirSync(link)`。** Windows 上同样能删 junction,但它读起来像“删目录”,而 `unlinkSync` 才是仓库现有的 junction 清理惯例。
**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。
## 后果
Windows 启动现在可以修复迁移后的安装或第二个 checkout,而不是以 `ERR_FS_EISDIR` 崩溃;POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。
+4 -2
View File
@@ -24,7 +24,7 @@
import { createRequire } from 'node:module'
import {
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync,
} from 'node:fs'
import { basename, dirname, join } from 'node:path'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void {
throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
}
if (readlinkSync(link) === target) return
rmSync(link)
// unlink deletes the reparse point itself on Windows too; rmSync treats a
// junction as a directory and throws EISDIR unless recursive.
unlinkSync(link)
}
try {
symlinkSync(target, link, 'junction')