Skip to content

[Bug] Windows: applyRelease 的 profile 锁遇 EPERM 直接抛异常退出,无任何重试(内核 dsh‑atomic‑write 对同类 EPERM 已有三层防护) #781

Open

Description

@blues-36

[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 即结束进程,界面无任何反馈。

  1. 环境
    项 值
    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)

  1. 崩溃日志(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),
与本问题无关,仅作对照归档。)

  1. 时间分布:成簇出现,非每次必现
    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),
所以不能据此认为已修复——只是这段时间没撞上那个瞬时状态。

  1. 代码定位(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 一字未改。

  1. 与内核实现的对比(本报告的核心论点)
    同一产品自带的 @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;

模块头注释也写明了这套保护的目的:

withFileLock serializes 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"当成已知平台特性在处理,
只是桌面壳这份手写的锁没有跟上。这不是设计差异,是同一产品内两套实现的健壮性不一致。


  1. 已排除项(附验证方法)
    怀疑点 验证方法 结论
    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 的那个外部因素尚未定位,我把它作为未决项如实标注,不做归因。

  1. 建议修复
    首选(改动最小、与内核一致)
    在 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 亦可),
并附日志路径。

  1. 与已有 issue 的关系(提交前已查重)
    搜索 withLock → 0 条(此问题未被记录)
    搜索 EPERM → 14 条,均非本问题:
    Windows: Harness fails with EPERM on mkdtemp when username contains non-ASCII characters (GBK/UTF-8 encoding bug) #188 EPERM 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 #618 PPT EPERM and zip ...(closed,不同模块)
    Windows: 沙箱会清掉工作区 ACL 中调用者自己的条目,导致升级后所有命令启动即失败 #579 Windows: 沙箱会清掉工作区 ACL ...(open,工作区 ACL,非 profile 锁)
    搜索 startup crash → Fix intermittent Windows Harness workspace crash #59(Koffi 0xC0000005,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(renderer exitCode=18,见另一份报告)
    结论:这是一个未被记录的新问题,与本机另一份"启动静默失败(沙箱)"报告是两回事:
    那份无日志、死在 Chromium 原生层;这份有完整 crash-*.log、死在 JS 层。

  1. 我能提供的配合
    在指定版本上复跑 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).

issue-attachments-crash-logs.zip

ISSUE-2-profile锁EPERM.md

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions