Skip to content

fix(startup): recover plugin failures and preserve both Market update paths - #380

Merged
yaojin3616 merged 12 commits into
mainfrom
fix/market-self-update-shared-tree
Sep 11, 2026
Merged

fix(startup): recover plugin failures and preserve both Market update paths#380
yaojin3616 merged 12 commits into
mainfrom
fix/market-self-update-shared-tree

Conversation

@yaojin3616

@yaojin3616 yaojin3616 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

启动报错此前依赖日志文字猜测插件归属,更新检查又只看 latest,无法选择仍兼容当前 DSH 的中间版本。本 PR 在加载和启动异常中保留插件归属,并从发布版本中选择符合宿主声明的升级目标。

Market 的共享目录与普通插件的 generation 链接采用各自的更新流程:共享目录 Market 通过原生 DSH CLI / pnpm 更新,并校验实际版本;generation 插件(包括已投影的 Market)在返回成功前切换 Profile 链接,保留旧 generation 供当前进程使用。Windows 只移除旧 junction 指针,不重命名已加载的目标目录。切换失败恢复旧链接及 desired 状态,兼容旧版暂存留下的 Market 元数据,避免覆盖其他插件。

正常启动在恢复门禁和 generation 投影完成后检查 Market 的实际安装版本。低于已验证基线 1.45.1 时,在 Harness 停止期间安装并验证新版本;保留更高版本以及用户卸载、禁用状态。启动时替换旧实目录同样支持失败回滚。

验证:最终隔离工作区 813 项测试、typecheck、build 通过。临时 Profile 使用真实 DSH CLI + pnpm 完成共享 Market 1.45.1 安装,官方 Market 版本读取确认成功,普通插件链接及后续启动投影保持正确。Windows CI(0829995,run 34555164267)全部通过,包括 junction 更新/失败回滚测试、类型检查、构建、三种缩放恢复界面检查、开发安装包及打包后 Harness 启动冒烟测试。未对用户当前安装的应用和实际 Profile 做手工验收。

yaojin3616 and others added 5 commits September 11, 2026 00:23
`dshmarket` is a core bundle the profile migration deliberately keeps in
the shared hoisted tree (`KEEP_IN_SHARED_TREE`), so its entry under
`profiles/web/node_modules` is a real directory, never a generation link.

Since #352, `runExternalMarketPluginInstall` routes every `add` — market
self-updates included — through `installGeneration` + the immediate
`publishInstalledGeneration`, which refuses a non-link directory by design:

    Cannot switch a non-link plugin directory: .../node_modules/dshmarket

So every `dshmarket` self-update failed, and because it throws before any
move, the market's rollback check also reports the previous build could
not be verified. Before #352 the boundary staged the switch for the next
cold start (`exposeMissingGenerationLinks` no-op'd on an existing entry),
so the update eventually landed; #352 turned that into a hard failure.

Branch on the spec: a `dshmarket` package goes through the ordinary
shared-tree `add --workspace-root`, which updates the directory in place —
the same asymmetry #351 already fixed on the uninstall side. The market's
registry is still resolved and pinned (#337), and any stale dshmarket
generation id is pruned from `desired.json`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#352 replaced the market install boundary's "staged for next restart"
publish step with publishInstalledGeneration(), which switches the profile
node_modules entry synchronously while Harness is running. For any entry
that is already in use that recreates the Windows locked-rename conflict
the generation model exists to avoid:

  - dshmarket is a core bundle kept in the shared tree, so its entry is a
    real directory. publishInstalledGeneration() refuses a non-link entry:
    every market self-update failed with "Cannot switch a non-link plugin
    directory", and the market's rollback check then reported the previous
    build could not be verified.
  - a loaded generation junction cannot be renamed on Windows, so a plugin
    update mid-session hangs on the rename retry — the market UI sits on
    "in progress" until the registry lock times out.

Restore the pre-#352 behavior: install the new generation, point
desired.json at it, expose only a brand-new link for the market's add
validation, and let the next cold start's projection switch the entry
while Harness is stopped. The #337 registry resolution added by #352 is
kept — only the immediate-switch step is reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@yaojin3616 yaojin3616 changed the title fix(market): update dshmarket in the shared tree, not via link switch fix(startup): recover plugin failures and preserve both Market update paths Sep 11, 2026
generation-migration.ts already keeps dshmarket in the shared profile
tree forever (KEEP_IN_SHARED_TREE) — it is a core bundle, never meant to
be a generation. Two recent paths broke that invariant by installing it
as one anyway:

  - runExternalMarketPluginInstall only routed dshmarket to the shared-tree
    updateSharedMarket() when its node_modules entry was NOT currently a
    symlink; once anything made it one, later "add" calls fell through to
    installGeneration()/publishInstalledGeneration() like any third-party
    plugin.
  - ensureMarketBaseline's default upgrader (upgradePluginToGeneration)
    installed the verified baseline as a generation and published it as a
    link, for dshmarket specifically.

Once dshmarket became a generation, whether it stayed a real directory or
a symlink depended on whichever code path touched it last — and a build
that reached the live profile that way (dshmarket 1.38.0, missing a host
`dsh-settings` export) could not be detected or rolled back afterward:
plugin-recovery excludes core bundles from its candidate list by design,
so the crash showed "plugin recovery detection: unresolved" with no way
to fix it short of hand-editing the profile.

Close both paths:

  - runExternalMarketPluginInstall always routes a dshmarket spec to
    updateSharedMarket(), which now also drops a stale generation symlink
    (leaving the generation directory itself untouched) before running
    pnpm, so a previously-projected Market collapses back to a real
    directory instead of being treated as an ordinary generation update.
  - plugin-upgrade.ts gains upgradeMarketInSharedTree(), which cleans up
    any stale generation ownership/override, drops a stale symlink, and
    reinstalls the whole shared profile tree via
    installProfileDependenciesWithDsh() (Harness already stopped at this
    point in startup) instead of installGeneration(). market-baseline.ts
    now defaults to it, and also repairs a dshmarket entry that is a
    generation link even when its version already meets the baseline —
    the version check alone can't see that shape and would otherwise leave
    it a generation forever.

upgradePluginToGeneration() itself is unchanged and still correctly used
for ordinary third-party plugin upgrades (recovery-page and Safe Mode
paths), which are unaffected — isThirdPartyPackageName()/CORE_BUNDLES
already exclude dshmarket from those candidate lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@yaojin3616

Copy link
Copy Markdown
Contributor Author

追加修复:dshmarket 彻底不进 generation 系统

上面几版修复之后,本地复现了一个新问题:dshmarket 被后续两次改动(4b468360829995)又装回了 generation(dshmarket+1.38.0+...),版本还低于基线,且用了主机 dsh-settings 没有的导出,直接把 Harness 崩了;更糟的是 plugin-recovery.tsdshmarket(核心 bundle)排除在恢复候选之外,崩溃后恢复界面完全定位不到它(plugin recovery detection: unresolved)。

根因:generation-migration.tsKEEP_IN_SHARED_TREE 早就规定 dshmarket 永远留在共享目录树、绝不进 generation,但两处新代码违反了这个约定:

  • runExternalMarketPluginInstall 只有在 node_modules/dshmarket 当前不是软链接时才走共享树更新;一旦它变成了链接,后续更新就会当成普通第三方插件走 installGeneration
  • ensureMarketBaseline 的默认升级函数直接把 dshmarket 装成了 generation 并发布链接。

38be336 把这两处都改回"从头到尾都不进 generation":

  • runExternalMarketPluginInstall 现在无条件把 dshmarket 交给 updateSharedMarket(),且该函数会先清掉遗留的 generation 软链接(只删链接本身,不动它指向的 generation 目录)再跑 pnpm,把已被污染成 generation 的 dshmarket 收回成实体目录。
  • 新增 plugin-upgrade.tsupgradeMarketInSharedTree():清理陈旧的 generation 归属/覆盖声明、去掉遗留链接,然后用 installProfileDependenciesWithDsh()(此时 Harness 已停止)整体重装共享 Profile 树,而不是 installGeneration()market-baseline.ts 默认改用它,并且新增"版本已达标但仍是 generation 链接也要修复"的判断——单看版本号看不出这种形态问题,否则会让它永远是 generation。

第三方插件的升级路径(upgradePluginToGeneration,恢复页/安全模式用)完全没动——isThirdPartyPackageName/CORE_BUNDLES 本来就把 dshmarket 排除在那些候选列表之外,不受影响。

新增/修改测试:test/plugin-upgrade.test.ts(新增)、test/market-baseline.test.tstest/generation-boundary.test.js。全量 vitest run test/ 除若干与本改动无关的既有环境性失败(缺 python3、Windows 未开发者模式的 symlink 测试、需要真实 pnpm 网络的集成测试)外全绿。

🤖 Generated with Claude Code

yaojin3616 and others added 6 commits September 11, 2026 13:10
38be336 closed the two writers that could install dshmarket as a
generation, but not the projector that recreated one on every launch.
`desired.json` still held a `dshmarket+1.38.0` pointer, and
`resolveEnabledGenerations` enables whatever it lists — so
`projectGenerations` re-linked the broken build each boot, ahead of any
repair. The symlink's mtime after installing the previous fix is the
proof: it was rewritten minutes later, by the fixed build itself.

Two failure modes compounded:

  - projection ran before the market repair, so the repair could only
    ever clean up after it; and
  - the repair sat behind `shouldDeferProfileMaintenance`, which is true
    while any plugin removal lacks `bootVerifiedAt` — a flag only a
    successful boot sets. A market that cannot load is what stops the
    boot, so the gate could never clear itself: the repair needed a boot,
    the boot needed the repair.

Enforce the invariant where it cannot be outrun, and let the repair run
early enough to matter:

  - `resolveEnabledGenerations` skips shared-tree-only packages, so a
    stray pointer is inert instead of a link. Nothing can project
    dshmarket, whatever `desired.json` says.
  - `demoteMarketGeneration` undoes a legacy projection before projection
    runs: link, pointer, ownership marker and override all go, while the
    `dependencies` declaration and `bundles` entry stay — dropping those
    reads as "the market was uninstalled" and every later repair declines
    to reinstall it.
  - both that and `ensureMarketBaseline` now run ahead of projection and
    ahead of the removal-verification gate. A frozen migration still
    blocks them; that path keeps the profile byte-for-byte for rollback.
  - `pnpmRunnerPath` reaches `installProfileDependenciesWithDsh`. Without
    it `ensureProfilePnpmShim` refuses to run a plain pnpm against a
    profile that still projects ordinary plugins, which failed the
    install with "the generation-aware pnpm runner is unavailable". The
    option is required rather than optional so the compiler catches the
    next omission.

Verified on a profile in exactly the reported state — a 1.38.0 market
generation plus an unverified removal — which now self-heals to a real
1.45.1 directory in the shared tree on the next launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant