Repository navigation
Description
[Bug] Windows: applyRelease 的 profile 锁遇 EPERM 直接抛异常退出,无任何重试(内核 dsh-atomic-write 对同类 EPERM 已有三层防护)
TL;DR
启动阶段,DesktopProjectManager.withLock() 用 openSync(lockPath, "wx", 384) 创建
profile 互斥锁。当系统返回 EPERM(而非预期的 EEXIST)时,代码走到
lib/main.js:3623 的 } else throw error;,异常沿启动链上抛,整个进程退出。
crash-*.log 里记录为:
Error: EPERM: operation not permitted,
open 'C:\Users\<user>\.dsh\profiles\desktop\lock'
errno: -4048, code: 'EPERM', syscall: 'open'
at DesktopProjectManager.withLock (lib/main.js:3607:17)
at DesktopProjectManager.applyRelease (lib/main.js:3592:14)
at lib/main.js:12314:32
at lib/main.js:7156:11
这不是设计取舍问题:同一产品自带的 @deepseek-ai/dsh-atomic-write 对
Windows 上完全相同的 EPERM 场景做了三层防护(把 EPERM 当锁竞争、额外重试一次、
rename 退避重试 8 次),而桌面壳这份手写的 withLock 一次都不重试。
期望:瞬时 EPERM 应被当作锁竞争/可重试错误处理,或至少给用户一个可见提示。
实际:首次 EPERM 即结束进程,界面无任何反馈。
- 环境
项 值
OS Windows 11 家庭中文版 25H2,Build 26200.9711,x64
DSH Desktop 0.2.1-rc.2(10 次崩溃全在此版本);现装 0.2.1-rc.3 复测
Electron / Node 44.7.0 / 24.21.0
安装方式 per-user NSIS,自定义路径D:\APP\deepseek Harness\Deepseek harness
数据目录C:\Users\LONG\.dsh(C: 与 D: 均 NTFS,BitLocker Off)
用户名LONG,纯 ASCII(排除 Windows: Harness fails with EPERM on mkdtemp when username contains non-ASCII characters (GBK/UTF-8 encoding bug) #188 那类非 ASCII 用户名问题)
Locale 系统/UI zh-CN(日志内记录locale: en)
- 崩溃日志(10 条,原文结构一致)
time: 2026-10-24T05:13:50.119Z
source: host
phase: startup
app: @deepseek-ai/dsh-desktop 0.2.1-rc.2
platform: win32 x64
electron: 44.7.0
node: 24.21.0
locale: en
shell pid: 25508
--- error ---
Error: EPERM: operation not permitted, open 'C:\Users\LONG\.dsh\profiles\desktop\lock'
at openSync (node:fs:621:18)
at func (node:electron/js2c/node_init:1:3581)
at DesktopProjectManager.withLock (file:///D:/APP/deepseek%20Harness/Deepseek%20harness/resources/app.asar/lib/main.js:3607:17)
at DesktopProjectManager.applyRelease (file:///D:/APP/deepseek%20Harness/Deepseek%20harness/resources/app.asar/lib/main.js:3592:14)
at file:///D:/APP/deepseek%20Harness/Deepseek%20harness/resources/app.asar/lib/main.js:12314:32
at file:///D:/APP/deepseek%20Harness/Deepseek%20harness/resources/app.asar/lib/main.js:7156:11 {
errno: -4048,
code: 'EPERM',
syscall: 'open',
path: 'C:\\Users\\LONG\\.dsh\\profiles\\desktop\\lock'
}
--- renderer console (error level, oldest first) ---
(no error-level renderer console output was captured)
(另有 1 条 2026-10-10 的 host exited 1073807364(0.2.0-rc.2),
与本问题无关,仅作对照归档。)
- 时间分布:成簇出现,非每次必现
10 次全部在0.2.1-rc.2(UTC):
2026-10-23 05:05:53 / 05:05:56 / 05:05:58 ← 5 秒内连撞 3 次
2026-10-23 15:01:50 / 15:01:59 / 15:06:31
/ 15:08:20 / 15:13:34 ← 12 分钟内 5 次
2026-10-24 05:13:39 / 05:13:50 ← 11 秒内 2 次
成簇特征说明:某个外部状态持续几十秒~几分钟,期间每次启动都失败,
状态过去后立刻恢复。这也解释了"重试有时能过"。
升级到 0.2.1-rc.3 后我做了 12 次连续启动压力测试(每次先结束进程,直接启动 exe):
12/12 全部成功。但 rc.3 的 withLock 代码与 rc.2 完全相同(见 §4),
所以不能据此认为已修复——只是这段时间没撞上那个瞬时状态。
- 代码定位(rc.2 与 rc.3 完全一致)
resources/app.asar→lib/main.js:
3591: async applyRelease() {
3592: await this.withLock(() => {
3593: readDesktopRuntime(this.runtime.dsh);
3594: migrateProfileSettings(this.paths.profile);
3595: createPluginProfile(this.paths.profile);
3596: removeLinkProjections(this.paths.profile);
3597: });
3598: }
3599: async withLock(operation) {
3600: mkdirSync(this.paths.profile, { recursive: true, mode: 448 });
3604: const lockPath = join(realpathSync(this.paths.profile), "lock");
3605: let descriptor;
3606: try {
3607: descriptor = openSync(lockPath, "wx", 384);
3608: } catch (error) {
3609: if (error.code === "EEXIST") {
3610: const lock = lstatSync(lockPath);
3611: if (lock.isSymbolicLink() || !lock.isFile()) throw new Error("...not a regular file");
3612: const owner = Number.parseInt(readFileSync(lockPath, "utf8").trim(), 10);
3613: let active = !Number.isSafeInteger(owner) || owner <= 0;
3614: if (!active) try {
3615: process.kill(owner, 0);
3616: active = true;
3617: } catch (signalError) {
3618: active = signalError.code !== "ESRCH";
3619: }
3620: if (active) throw new Error("desktop project: another profile operation is active");
3621: unlinkSync(lockPath);
3622: descriptor = openSync(lockPath, "wx", 384);
3623: } else throw error; // ← EPERM / EACCES / EBUSY 全部走到这里,直接结束进程
3624: }
3625: try {
3626: writeSync(descriptor, `${String(process.pid)}\n`);
3627: fsyncSync(descriptor);
3628: return await operation();
...调用链(对应崩溃栈):
7156: await prepare(); ← backend.start() 的准备阶段
12314: await Promise.all([manager.applyRelease(), prepareHostEnvironment()]);
3592: await this.withLock(() => { ... });
3607: descriptor = openSync(lockPath, "wx", 384); ← EPERM 在这里
3623: } else throw error; ← 直接上抛 = 进程退出
rc.2 与 rc.3 的差异:我把两版 lib/main.js 全文比对过,仅一处不同——
desktopClientVersion() 的返回值 "0.2.1-rc.2" → "0.2.1-rc.3"。
上述 withLock / applyRelease 一字未改。
- 与内核实现的对比(本报告的核心论点)
同一产品自带的@deepseek-ai/dsh-atomic-write@0.2.1-rc.3,对完全相同的 Windows EPERM 场景
有三层防护:
① 把 EPERM 当作锁竞争,而不是致命错误
async function isLockContention(error, lockPath) {
const code = error?.code;
if (code === "EEXIST") return true;
if (code !== "EPERM") return false;
try { await lstat(lockPath); return true; } catch { return false; }
}② Windows 上遇到"未确认的 EPERM"额外再试一次
} catch (error) {
if (!await isLockContention(error, lockPath)) {
if (process.platform !== "win32" || error?.code !== "EPERM" || retriedUnconfirmedPermissionError) throw error;
retriedUnconfirmedPermissionError = true; // win32 + EPERM:重试一次后仍失败才抛
} else if (await takeOverExitedLock(lockPath)) continue;
}③ rename 遇 EACCES / EBUSY / EPERM 指数退避重试 8 次(20ms → 200ms)
const WINDOWS_TRANSIENT_RENAME_ERRORS = new Set(["EACCES", "EBUSY", "EPERM"]);
const WINDOWS_RENAME_RETRY_INITIAL_MS = 20;
const WINDOWS_RENAME_RETRY_MAX_MS = 200;
const WINDOWS_RENAME_RETRY_LIMIT = 8;模块头注释也写明了这套保护的目的:
withFileLockserializes cross-process writers ... so a read-modify-write cycle can never
resurrect a state another writer just replaced ... A lock whose recorded holder process no
longer exists is taken over.
也就是说:团队已经把"Windows 上这套 fs 操作会瞬时报 EPERM"当成已知平台特性在处理,
只是桌面壳这份手写的锁没有跟上。这不是设计差异,是同一产品内两套实现的健壮性不一致。
- 已排除项(附验证方法)
怀疑点 验证方法 结论
ACL / 权限Get-Acl:SYSTEM / Administrators / 用户 均 FullControl 排除
路径或打开方式 用同一 exe 的ELECTRON_RUN_AS_NODE=1跑脚本,对同一路径、同样openSync(p,'wx',384),连续多次全部成功 排除(关键判据:同样的写入在非 GUI 模式可行)
重解析点 / junction.dsh→profiles→desktop链路均为普通目录(Attributes=Directory、LinkType空) 排除
delete-pending 竞争 专项实验:进程持 fd 打开 lock → 同进程unlink(文件进入 delete-pending)→ 再openSync(...,'wx')→ 成功,code 不是 EPERM 排除
杀软 HIPS 本机唯一第三方文件过滤驱动为火绒(fltmc可见sysdiag);查Huorong\Sysdiag\log.db的HrLogV3_60、HipsTrayMsg_60,无任何与 DSH 相关的拦截记录 排除(但无法排除"扫描期间瞬时拒绝而不记录"的可能)
Chromium 沙箱参数 带 / 不带--no-sandbox均复现 排除
启动期并发L12314的Promise.all中另一个任务是prepareHostEnvironment(L12062),其实现仅为hostEnvironment = await loginShell;,不触碰文件系统 排除
用户名编码 用户名为LONG,纯 ASCII 排除(区别于 Windows: Harness fails with EPERM on mkdtemp when username contains non-ASCII characters (GBK/UTF-8 encoding bug) #188)
文件系统 / 加密 C:、D: 均 NTFS;BitLocker Off / FullyDecrypted 排除
未定位到的那一环:EPERM 的直接触发者(哪个驱动/进程在那一瞬间拒绝了 CREATE_NEW)尚未抓到现行,
因为它不留日志。但这与本报告的建议无关——即使触发者来自外部,withLock也不应该在
一次 EPERM 后就让整个应用退出,尤其内核包已经示范了正确做法。
6.1 我在本机已经做过的修复尝试(含结果)
尝试 具体做法 结果
1 升级到最新版 rc.2 → rc.3(官方 nightly,sha512 与 nightly.yml 一致、EV 签名 Valid) 无效:两版 lib/main.js 全文比对仅差版本号字符串,withLock 一字未改
2 清理僵尸锁 启动前读取 lock 内 pid,进程不存在则删除 只对"残留锁"有效;对 EPERM 无效(EPERM 发生时 lock 尚不存在)
3 外部递增重试 包装器:3/6/10/20/30 秒重试,共约 69 秒 有效:把"单次瞬时 EPERM = 进程退出"变成可自愈;冷启动实测 3 秒成功
4 压力验证 rc.3 上连续启动 12 次(每次先结束进程,直接启动 exe) 12/12 成功,未复现(但这不能说明已修复,只是没撞上那个瞬时状态)
5 权限 / 路径 / 杀软 / 并发排查 见 §6 表格 全部排除
6 干净重装 未做(避免动 13.6 GB 附件数据;且问题在代码层,重装不会改变逻辑) —
第 3 项只是规避,不修复根因;根因仍需在 withLock 里加退避重试或复用内核实现。
6.2 问题归属:是这台电脑的问题,还是软件本身的问题?
争议点 归属 依据
一次 EPERM 就让整个进程退出 软件代码缺陷(与机器无关) 内核 dsh-atomic-write 对同一 Windows 场景做了三层防护;桌面壳 withLock 零重试
启动失败只有日志、界面零提示 软件产品缺陷(与机器无关) 用户侧只能看到"双击没反应"
EPERM 的直接触发者 未定位(可能来自外部过滤驱动) 该触发者不留日志、抓不到现行;但即便外部有责任,"零重试"仍是软件侧问题
安装包损坏 / 安装不完整 不成立 sha512 与官方一致;exe 与安装包 EV 签名均 Valid;同一 exe 的 ELECTRON_RUN_AS_NODE 模式可正常执行
杀软拦截 未证实 火绒 HrLogV3_60 / HipsTrayMsg_60 无任何相关记录;但无法完全排除"扫描期间瞬时拒绝且不记录"
ACL / 重解析点 / delete-pending / 磁盘 / 加密 不是原因 逐项做过实验,见 §6
用户名非 ASCII 不是原因 本机用户名 LONG,纯 ASCII
准确说法:这不是"你的电脑坏了"。可以确定的部分是软件侧的健壮性缺陷
(一次 EPERM 即退出、零提示),这部分与机器无关、换任何机器都可能发生;
触发 EPERM 的那个外部因素尚未定位,我把它作为未决项如实标注,不做归因。
- 建议修复
首选(改动最小、与内核一致)
在withLock的 catch 中,对EPERM/EACCES/EBUSY做有限退避重试,再抛出:
} else if (["EPERM", "EACCES", "EBUSY"].includes(error.code)) {
// 与 @deepseek-ai/dsh-atomic-write 保持一致的退避节奏
// 20ms → 200ms,最多 5 次;仍失败则抛出(保留原有错误语义)
}次选(结构上更好)
applyRelease 直接复用 @deepseek-ai/dsh-atomic-write 的 withFileLock(filename, operation),
不再维护第二套锁实现。该包已处理:EPERM 竞争判定、死锁接管(含 pid 复用保护)、
Windows rename 退避。
顺带建议(用户体验,与 §2 呼应)
启动阶段(phase: startup)失败目前只落盘 crash-*.log,界面上什么都没有,
用户看到的是"双击没反应"。建议在启动早期失败时也给一个可见提示(原生 MessageBox 亦可),
并附日志路径。
- 与已有 issue 的关系(提交前已查重)
搜索withLock→ 0 条(此问题未被记录)
搜索EPERM→ 14 条,均非本问题:
Windows: Harness fails with EPERM on mkdtemp when username contains non-ASCII characters (GBK/UTF-8 encoding bug) #188EPERM on mkdtemp when username contains non-ASCII characters(closed;本机用户名纯 ASCII)
Windows follow-up to #591: recovery UI smoke fix, PPT EPERM and zip path fixes #618PPT EPERM and zip ...(closed,不同模块)
Windows: 沙箱会清掉工作区 ACL 中调用者自己的条目,导致升级后所有命令启动即失败 #579Windows: 沙箱会清掉工作区 ACL ...(open,工作区 ACL,非 profile 锁)
搜索startup crash→ Fix intermittent Windows Harness workspace crash #59(Koffi0xC0000005,closed)、[Bug] Renderer fails to launch (reason=launch-failed exitCode=18) on ~19% of starts unless the app is elevated; Edge works fine on the same machine #725/【bug】内置管理员账户(RID 500)下渲染进程必定启动失败(exitCode=18),应用完全无法打开 #732(rendererexitCode=18,见另一份报告)
结论:这是一个未被记录的新问题,与本机另一份"启动静默失败(沙箱)"报告是两回事:
那份无日志、死在 Chromium 原生层;这份有完整crash-*.log、死在 JS 层。
- 我能提供的配合
在指定版本上复跑 12 次压力启动脚本,回报命中率
提供 Process Monitor 抓取(CreateFile返回ACCESS_DENIED的调用栈与发起进程)
需要时临时停用火绒(含三个内核驱动)后对照复测
提供全部crash-*.log(已附)
English summary
At startup, DesktopProjectManager.withLock() creates the profile lock with
openSync(lockPath, "wx", 384). Only EEXIST is handled; any other code - including the
transient EPERM that Windows filesystems/filters can return for CREATE_NEW - hits
lib/main.js:3623 } else throw error; and the whole process exits during applyRelease().
Observed 10 times on 0.2.1-rc.2, always in clusters (3 within 5 s, 5 within 12 min,
2 within 11 s), i.e. a transient external state that lasts seconds to minutes.
Crucially, the bundled @deepseek-ai/dsh-atomic-write already handles exactly this
Windows behaviour:
isLockContention() treats EPERM as lock contention (confirms via lstat)
withFileLock() retries an unconfirmed EPERM once on win32
renameAtomicTemp() backs off 8 times on EACCES/EBUSY/EPERM (20 ms → 200 ms)
takeOverExitedLock() reclaims a lock whose holder is gone (with PID-reuse protection)
So the platform quirk is known to the team; only the desktop shell's hand-written lock
is missing the guard.
Ruled out with experiments: ACLs, path/symlink issues, delete-pending races
(a dedicated test shows wx still succeeds), antivirus HIPS logs (no entries), --no-sandbox,
concurrent startup path, non-ASCII username, filesystem/encryption layer.
A plain ELECTRON_RUN_AS_NODE probe on the same exe creates the same file at the same path
with the same wx mode successfully - so the refusal only happens inside the full Electron
startup.
Suggested fix: reuse withFileLock from @deepseek-ai/dsh-atomic-write, or add a bounded
backoff retry for EPERM/EACCES/EBUSY in withLock (20 ms → 200 ms, ~5 attempts).
Also consider surfacing startup failures in the UI instead of only writing crash-*.log.
Not a duplicate: withLock search returns 0 results; related-but-different issues are
#188 (mkdtemp EPERM, non-ASCII username), #579 (workspace ACL), #725/#732 (renderer exitCode=18).
Activity