Skip to content

fix(market): disable the generation pointer when uninstalling the market - #351

Merged
yaojin3616 merged 1 commit into
mainfrom
fix/market-uninstall-generation-330
Sep 10, 2026
Merged

yaojin3616 merged 1 commit into
mainfrom
fix/market-uninstall-generation-330

Conversation

@yaojin3616

Copy link
Copy Markdown
Contributor

Fixes #330

问题

「设置 → 插件 → 插件市场 → 卸载插件市场」后重启,插件市场原样回来。

卸载走的是 IPC market:uninstall → uninstallMarketAndRestart() → removeProfilePluginWithDsh(),也就是 dsh plugin remove --workspace-root dshmarket。这是 pnpm 层面的删除,只改 dependencies 和 node_modules。

但当 dshmarket 是 generation 形态时,desired.json 才是权威:卸载自身触发的那次重启里,prepareGenerationsForLaunch() → projectGenerations() 以 desired.json 为准,把 junction、dependencies、bundles、generationProjection、pnpm.overrides 全部重建回来。加上 pnpm runner 的 suspendGenerationProjectionForPnpm() / restore() 会在命令前后把投影字段摘除再写回,pnpm 删除的那点效果也留不住。

结果就是:卸载"成功",重启后一切照旧。

修复

按安装形态分支:

  • generation 形态 → uninstallGenerationPlugin()(disableGeneration + 重新投影 + 校验),也就是恢复流程和安全模式一直在用的那条路;
  • 其余形态 → 保持原有的 pnpm 删除,行为不变。

形态在删除之前判定(新增 isProjectedGenerationPlugin())。原因是 uninstallGenerationPlugin() 的 false 有两种含义——"不是 generation,请走 pnpm" 和 "是 generation,但禁用失败"——混为一谈正是"指针还在却报成功"的来源。先问清楚,禁用失败就如实抛错。

为什么不复用 removeProfilePluginCompletely()

issue 里建议直接改调它,但那条路走不通:dshmarket 在 CORE_BUNDLES 里,isThirdPartyPackageName() 返回 false,beginRemoval() 会以 Refusing to remove core package dshmarket 拒绝。那个守卫的作用是防止插件恢复/安全模式流程扯掉核心 bundle,不该为这次用户主动发起的卸载而放宽。所以只取它内部的 generation 分支。

测试

test/generation-launch.test.ts 新增 4 条:

  • isProjectedGenerationPlugin 能区分 generation 形态与普通 manifest 形态;
  • 卸载 market generation 后再走一次启动投影,desired.json、dependencies、bundles、pnpm.overrides、node_modules/dshmarket 全部干净;
  • 回归记录:只做 manifest 层面删除(模拟 dsh plugin remove 的结果)时,下一次启动投影会把它完整重建回来——这正是本 issue 的根因;
  • 市场卸载确实经过 generation 路径,同时保留 pnpm 路径。

去掉本 PR 的 src 改动后,其中 2 条失败;加上后全绿。

npm run typecheck                          ✅
vitest run test/generation-launch.test.ts  ✅ 9 passed
+ plugin-removal / market-installer / generation-{registry,projection,installer,migration}  ✅ 72 passed

全量 vitest run:733 passed / 1 failed,3 个文件失败(ppt-activation、ppt-fonts、safe-mode-runtime)。这 3 个在未改动的 main 上同样失败(本地缺 PPT 运行时产物),与本 PR 无关。

说明

issue 里提到「HTTP 路径是 generation-aware 的,只是桌面端没选它」——实际上市场自我卸载的 HTTP 路径 (runUninstall()) 也不走 runPlugin(),而是直接 runProfileCommand(buildMarketUninstallArguments()),同样是纯 pnpm 删除。所以改走 HTTP 并不能解决问题,得在这里修。

issue 末尾「附加信息」里的 bundles 残留问题不在本 PR 范围内——那条已由 8b018c9(syncBundles: true)修复,包含在 0.8.0-rc.3 及以后,v0.7.2 没有。

Uninstalling the plugin market ran `dsh plugin remove --workspace-root
dshmarket`, which edits only `dependencies` and `node_modules`. When
dshmarket is installed as a generation, `desired.json` is what the next
launch projects from — so prepareGenerationsForLaunch() rebuilt the
market during the restart the uninstall itself triggers, and the whole
operation looked like it had done nothing.

Branch on the install form instead: a generation goes through
uninstallGenerationPlugin() (disableGeneration + reproject), everything
else keeps the existing pnpm removal. The form is read BEFORE the
removal, so a generation whose disable fails reports a failure rather
than a success with the pointer still in place.

Not routed through removeProfilePluginCompletely(): dshmarket is a
CORE_BUNDLES name and beginRemoval() refuses it by design, which is the
guard that stops recovery and Safe Mode from tearing out a core bundle.

Fixes #330
@yaojin3616
yaojin3616 merged commit b854c29 into main Sep 10, 2026
6 checks passed
@yaojin3616
yaojin3616 deleted the fix/market-uninstall-generation-330 branch September 10, 2026 09:34
yaojin3616 added a commit that referenced this pull request Sep 11, 2026
… paths (#380)

* fix(market): update dshmarket in the shared tree, not via link switch

`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>

* fix(startup): preserve plugin ownership and select compatible updates

* fix(market): stage dshmarket/loaded-bundle updates for next restart

#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>

* fix(market): enforce verified baseline before normal startup

* fix(market): preserve shared-tree and generation update contracts

* fix(market): keep dshmarket out of the generation system entirely

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>

* fix(market): stop projection from resurrecting a dshmarket generation

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>

* fix(recovery): check and handle blocking plugins individually

* fix(recovery): batch upgrades and invalidate repaired plugin failures

* fix(recovery): automatically combine plugin upgrades and removals

* fix(market): execute pnpm runner through linked package paths

* fix(recovery): use Electron networking and retry failed market checks

---------

Co-authored-by: Claude Sonnet 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.

[Bug] 卸载插件市场(dshmarket)后重启仍被重新投影回来

1 participant