Просмотр исходного кода

fix(desktop): clarify profile recovery scope and diagnostics

Turtle 2 недель назад
Родитель
Сommit
04794bd90e

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/architecture/2026-09-15-desktop-native-fatal-recovery.md
-2026-09-15-desktop-native-fatal-recovery.md: b42fdd07b33c52d3b09b735b7f70d14bc20c507b
-2026-09-15-desktop-native-fatal-recovery.zh.md: 9f42ba4213a5f9e473ca059b8ee2e2c2b3a1200c
+2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
+2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
 
 Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
 
-The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Web launchers can call the same function while owning their shutdown and write exclusion. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
 
 The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
 
@@ -26,4 +26,4 @@ A Web modal depends on client initialization, while a second recovery document a
 
 ## Consequences
 
-Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
+Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. A malformed home-level patch still blocks startup after profile recovery and requires manual repair. Backups accumulate in the profile directory without automatic pruning; users remove them when no longer needed. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
 
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。Web 启动器可在负责关闭和排除并发写入的前提下调用同一函数。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
 
 Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
 
@@ -26,4 +26,4 @@ Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染
 
 ## Consequences
 
-恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
+恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。损坏的 home 级 patch 在 profile 恢复后仍会阻止启动,需要手动修复。备份在 profile 目录中累积,不会自动清理;用户在不再需要时删除。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 apps/desktop/README.md
-README.md: 551f97d98ff70d5f3b566e2288b412762c7ab54e
-README.zh.md: c44a12992a99774d2f372119c3f52b0d7eda1a46
+README.md: ccd387fcaea9c7c6c844db93ed203cb6c2da2389
+README.zh.md: f246dfb92ac67ecf7873b20f8e17d8c13b771d67

+ 2 - 2
apps/desktop/README.md

@@ -62,11 +62,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up configuration, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
 
 Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
+Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
 
 Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -62,11 +62,11 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份配置并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
 
 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`,无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
+恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
 
 包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 

+ 2 - 2
apps/desktop/src/locale.ts

@@ -9,7 +9,7 @@ export const en = {
   exitApplication: 'Exit',
   restartApplication: 'Restart',
   recoveryOperationFailed: 'The recovery operation failed',
-  disableThirdPartyPlugins: 'Disable third-party plugins, back up configuration, and restart',
+  disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -59,7 +59,7 @@ export const zh = {
   exitApplication: '退出',
   restartApplication: '重启',
   recoveryOperationFailed: '恢复操作失败',
-  disableThirdPartyPlugins: '禁用第三方插件、备份配置并重启',
+  disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',

+ 2 - 1
apps/desktop/src/main.ts

@@ -37,7 +37,8 @@ const recovery = new DesktopFatalRecovery({
   stop: () => { shuttingDown = true; return stopForRecovery() },
   disablePlugins: async () => {
     const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
-    await manager.disableAllPlugins()
+    const backupPath = await manager.disableAllPlugins()
+    console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
   },
   exit: () => { app.quit() },
   restart: () => { app.relaunch(); app.quit() },

+ 4 - 5
apps/desktop/src/project-manager.ts

@@ -123,12 +123,11 @@ export class DesktopProjectManager {
 
   /**
    * Back up the profile patch and disable third-party bundles without loading application resources.
-   * @returns Completion of the locked profile write; the caller must stop the Host first.
+   * The caller must stop the Host first.
+   * @returns Backup path after the locked profile write, or undefined if the patch was absent.
    */
-  async disableAllPlugins(): Promise<void> {
-    await this.withLock(() => {
-      sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles)
-    })
+  async disableAllPlugins(): Promise<string | undefined> {
+    return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
   }
 
   /** Read the dsh version supplied by this application's verified resources. */

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-en.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
 Exit
 Restart
-Disable third-party plugins, back up configuration, and restart
+Disable third-party plugins, back up profile patch, and restart

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-zh-CN.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
 退出
 重启
-禁用第三方插件、备份配置并重启
+禁用第三方插件、备份 profile patch 并重启

+ 13 - 2
apps/desktop/tests/main-startup.spec.ts

@@ -97,7 +97,10 @@ const harness = await vi.hoisted(async () => {
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     mutateFailure: vi.fn<() => void>(),
-    disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
+    disableAllPlugins: vi.fn(async () => {
+      pluginsEnabled = false
+      return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
+    }),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
@@ -167,6 +170,7 @@ beforeEach(() => {
   harness.reset()
   harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
   vi.spyOn(console, 'error').mockImplementation(() => {})
+  vi.spyOn(console, 'info').mockImplementation(() => {})
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
   vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -392,7 +396,7 @@ describe('desktop main startup', () => {
     harness.prepared.reject(new Error('runtime resources missing'))
     await harness.dialogShown.promise
     expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
-    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up configuration, and restart'])
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
@@ -448,6 +452,13 @@ describe('desktop main startup', () => {
     await harness.quitCompleted.promise
     expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
     expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+    if (response === 2) {
+      expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', {
+        profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged',
+      })
+    } else {
+      expect(console.info).not.toHaveBeenCalled()
+    }
     expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })

+ 3 - 2
apps/desktop/tests/project-manager.spec.ts

@@ -116,10 +116,11 @@ describe('desktop external plugin profile', () => {
     const patch = join(manager.paths.profile, 'cordis.patch.yml')
     writeFileSync(patch, ': broken')
     const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
-    await uninitialized.disableAllPlugins()
+    const backupPath = await uninitialized.disableAllPlugins()
     expect(existsSync(patch)).toBe(false)
     const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
     expect(backups).toHaveLength(1)
+    expect(backupPath).toBe(join(manager.paths.profile, backups[0]!))
     expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
     expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
     const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
@@ -135,7 +136,7 @@ describe('desktop external plugin profile', () => {
 
   it('needs no runtime or package manifest when no plugins have been installed', async () => {
     const { manager } = setup()
-    await manager.disableAllPlugins()
+    await expect(manager.disableAllPlugins()).resolves.toBeUndefined()
     expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
     expect(existsSync(manager.paths.lock)).toBe(false)
   })

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/boot/app-boot/README.md
-README.md: da8fb0493b8e47dd074eeff3660b50d437bbf041
-README.zh.md: dc7813566742d6715e83bd80fa1e86f21c04ddfc
+README.md: d7168c82ade6fd301f1100157ef0336cad619146
+README.zh.md: 7f918e5e38eef1eaf7060a84720825b8d5709b1c

+ 2 - 1
packages/boot/app-boot/README.md

@@ -60,7 +60,7 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. The default link mode materializes the existing shared and profile-owned fallback links, so supported launch behavior stays unchanged. Internal callers and test harnesses can instead install the generation through Node's ESM and CommonJS resolvers in runtime mode, or materialize and verify the same generation in dual mode.
 
-`sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery for Web launchers and Desktop without loading plugins or parsing patches. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<timestamp>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds, incremented when a backup with that name already exists. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
+`sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<timestamp>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (`-1`, `-2`, …) without changing it. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
 
 ### Previewing the effective configuration
 
@@ -131,6 +131,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section |
 | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback |
 | [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates |
+| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation |
 | [`src/profile-resolution/`](src/profile-resolution/) | Runtime resolver, package-metadata service, and built Worker bootstrap |
 | — | No runtime invariant companion is published; one registration owns each resolver generation, and dual mode compares the independently materialized result at resolution time. |
 

+ 2 - 1
packages/boot/app-boot/README.zh.md

@@ -60,7 +60,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。默认 link 模式会物化现有的共享 fallback 链接与 profile 自有 fallback 链接,因此受支持的启动行为保持不变。内部调用方和测试工具可以改用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以使用 dual 模式,同时物化并校验同一份 generation。
 
-`sanitizeProfile(binName, profileDir, bundles)` 为 Web 启动器和 Desktop 提供文件恢复,无需加载插件或解析 patch。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<timestamp>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时递增,避免覆盖。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
+`sanitizeProfile(binName, profileDir, bundles)` 提供文件恢复,无需加载插件或解析 patch。Desktop 在原生致命错误恢复中调用它。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<timestamp>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时追加序号(`-1`、`-2`、……),时间戳保持不变。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
 
 ### 预览生效配置
 
@@ -131,6 +131,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 | [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 |
 | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 |
 | [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 |
+| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 |
 | [`src/profile-resolution/`](src/profile-resolution/) | 运行时 resolver、package metadata 服务与构建后 Worker bootstrap |
 | — | 不发布运行时不变式伴生入口;每个 resolver generation 只有一个 registration 所有,dual 模式在解析时比较独立物化的结果。 |
 

+ 7 - 5
packages/boot/app-boot/src/profile-sanitize.ts

@@ -1,4 +1,4 @@
-/** Profile recovery shared by Web launchers and the Desktop shell. */
+/** Filesystem recovery for callers that own profile shutdown and write exclusion. */
 
 import { existsSync, renameSync } from 'node:fs'
 import { join } from 'node:path'
@@ -13,14 +13,16 @@ import { writeProfileBundles } from './profile-plugins.ts'
  * @param binName - Diagnostic prefix for invalid profile manifests.
  * @param profileDir - Profile directory to recover without loading its plugins.
  * @param bundles - Ordered bundles to enable after recovery.
- * @returns Renamed patch path with a unique `.bak-<timestamp>` suffix in Unix milliseconds, or undefined if absent.
+ * @returns Backup path with a Unix millisecond timestamp and optional collision ordinal, or undefined if absent.
  */
 export function sanitizeProfile(binName: string, profileDir: string, bundles: readonly string[]): string | undefined {
   const manifest = existsSync(join(profileDir, 'package.json')) ? readProfileManifest(binName, profileDir) : undefined
   const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
-  let timestamp = Date.now()
-  let backupPath: string | undefined = `${patchPath}.bak-${timestamp}`
-  while (existsSync(backupPath)) backupPath = `${patchPath}.bak-${++timestamp}`
+  const backupBase = `${patchPath}.bak-${Date.now()}`
+  let backupPath: string | undefined = backupBase
+  let ordinal = 0
+  // Caller-owned write exclusion keeps the selected destination absent until rename.
+  while (existsSync(backupPath)) backupPath = `${backupBase}-${++ordinal}`
   try {
     renameSync(patchPath, backupPath)
   } catch (error) {

+ 8 - 5
packages/boot/app-boot/tests/profile-sanitize.spec.ts

@@ -18,10 +18,10 @@ afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
-function fixture(name = 'web') {
+function fixture() {
   const root = mkdtempSync(join(tmpdir(), 'dsh-profile-sanitize-'))
   roots.push(root)
-  const dir = join(root, 'profiles', name)
+  const dir = join(root, 'profiles', 'web')
   const bundles = PROFILE_TEMPLATES.web!.bundles
   initProfile(dir, [...bundles, 'broken-plugin'])
   const manifestPath = join(dir, 'package.json')
@@ -32,8 +32,8 @@ function fixture(name = 'web') {
   return { root, dir, bundles, manifest, manifestPath, patch }
 }
 
-it.each(['web', 'desktop'])('recovers the %s profile without loading its broken plugins or patch', (name) => {
-  const { dir, bundles, manifest, patch } = fixture(name)
+it('recovers a profile without loading its broken plugins or patch', () => {
+  const { dir, bundles, manifest, patch } = fixture()
   const packageDir = join(dir, 'node_modules', 'broken-plugin')
   mkdirSync(packageDir, { recursive: true })
   const pluginManifest = join(packageDir, 'package.json')
@@ -57,9 +57,12 @@ it('preserves previous backups across retries and later recovery actions', () =>
   writeFileSync(patch, 'second patch')
   const second = sanitizeProfile('test', dir, bundles)!
   expect(first).toBe(`${patch}.bak-${timestamp}`)
-  expect(second).toBe(`${patch}.bak-${timestamp + 1}`)
+  expect(second).toBe(`${patch}.bak-${timestamp}-1`)
+  writeFileSync(patch, 'third patch')
+  expect(sanitizeProfile('test', dir, bundles)).toBe(`${patch}.bak-${timestamp}-2`)
   expect(readFileSync(first, 'utf8')).toBe(': broken YAML')
   expect(readFileSync(second, 'utf8')).toBe('second patch')
+  expect(readFileSync(`${patch}.bak-${timestamp}-2`, 'utf8')).toBe('third patch')
 })
 
 it('does not create an absent profile and backs up a patch even without a manifest', () => {