fix: a failed enable leaves every view of the state as it was (#575) (#585)

* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* test: pin the toggle patch-layer gate (#575)

Bundle-plugin flow: a failed enable leaves the user patch untouched (still disabled) instead of flipping the durable layer into a boot crash loop.

* fix: a failed enable leaves every view of the state as it was (#575)

Completes @JINITAIMEI121's fix in #584, whose diagnosis and asymmetry are
kept verbatim: a failed ENABLE must not flip the durable layer, a failed
DISABLE must — the user asked for OFF, and a failed unmount leaves the
plugin live only for this session.

Their patch-layer gate closed the hole in cordis.patch.yml. Two of the
three views the report described were still flipped: setPluginEnabled
recorded the choice BEFORE attempting the mount and persisted state.json
whatever happened, so after a failed enable the market's own store and
its in-memory set both said "enabled" while the patch layer said
"disabled".

That left two problems. The persisted views disagreed, so which one won
at the next boot depended on load order — harder to diagnose than the
original bug. And a CLIENT-ONLY plugin has no bundle rows at all, so
`patchRows` is empty, the patch gate never runs, and state.json is its
only durable state: for that plugin kind the crash loop was untouched.
The reporter measured 24 restarts.

The in-memory set is now restored before persisting, so the reply, the
store and the patch layer tell one story.

Tests: the bundle-plugin case also asserts the market's own answer, and a
new client-only case covers the path the patch gate cannot reach. Both
failed before this change with `expected [] to include …`.

Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com>

---------

Co-authored-by: eeeeeeaaaa12 <1303576427@qq.com>
Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com>
This commit is contained in:
fkysly
2026-09-13 14:54:19 +08:00
committed by GitHub
co-authored by JINITAIMEI121 eeeeeeaaaa12
parent 1ecdd8eaa0
commit 310ac4a58c
2 changed files with 108 additions and 7 deletions
+41 -6
View File
@@ -487,14 +487,31 @@ export function mountMarketRoutes(
}
/**
* Apply one enable/disable request: persist the choice in state.json, then
* drive the live composition. Covers every mount form — hot mounts and
* client-only shims go through hotUnmount/hotMount, bundle-layer entries
* through setEntryDisabled. Enabling a THEME goes through the caller's
* activateTheme instead so the Themes tab's exclusivity stays intact.
* Apply one enable/disable request: drive the live composition, then
* persist the choice in state.json. Covers every mount form — hot mounts
* and client-only shims go through hotUnmount/hotMount, bundle-layer
* entries through setEntryDisabled. Enabling a THEME goes through the
* caller's activateTheme instead so the Themes tab's exclusivity stays
* intact.
*
* A FAILED ENABLE LEAVES EVERYTHING AS IT WAS (#575). The choice used to be
* recorded before the mount was attempted and persisted whatever happened,
* so enabling a plugin that crashes on import — deterministically, every
* time — wrote "enabled" into state.json anyway. The next boot tried the
* import again and died again; the reporter measured 24 restarts before
* restoring the disable by hand. The toggle route's patch-layer gate
* (@JINITAIMEI121 in #584) closed the same hole in cordis.patch.yml; this
* closes it in the market's own store, which is the ONLY durable state a
* client-only plugin has — that plugin kind has no bundle rows, so the
* patch gate never runs for it.
*
* A failed DISABLE still persists, and that asymmetry is deliberate: the
* user asked for OFF, and a failed unmount leaves the plugin live only for
* this session. There the durable disable is the contract, not an error.
*/
async function setPluginEnabled(name: string, enabled: boolean): Promise<{ ok: boolean; reason?: string }> {
const dir = activeProfileDir
const wasDisabled = disabled.has(name)
if (enabled) disabled.delete(name)
else disabled.add(name)
let ok: boolean
@@ -522,6 +539,13 @@ export function mountMarketRoutes(
ok = true
}
}
if (!ok && enabled) {
// Put the in-memory view back before persisting: it is the same object
// the route reports as `disabled`, so restoring it keeps the reply, the
// store and the patch layer telling one story.
if (wasDisabled) disabled.add(name)
logEvent('warn', 'toggle', `${name}: enable failed; leaving it disabled rather than persisting a state that crashes at boot (#575)`)
}
writeMarketState(dir, { disabled, groups, groupOrder })
return { ok, reason }
}
@@ -2109,7 +2133,18 @@ export function mountMarketRoutes(
}
}
let patchWrite: { ok: boolean; reason: string | null } | null = null
if (patchRows.length > 0) {
// #575: a failed ENABLE must not flip the durable patch layer.
// The hot-mount failure may be deterministic (a plugin that
// crashes on import), and persisting "enabled" turns a transient
// in-session error into a boot crash loop — the loader re-applies
// the flipped rows on every start. The frontend already shows the
// plugin as still disabled, and the next explicit enable retries
// cleanly. Disables keep their unconditional write: a failed
// unmount leaves the plugin live in-session, and the user asked
// for it OFF — the durable disable is then the contract, not an
// error.
const patchGate = ok || !enabled
if (patchRows.length > 0 && patchGate) {
for (const rowId of patchRows) {
const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId)
if (!result.ok && patchWrite === null) patchWrite = result
+67 -1
View File
@@ -18,7 +18,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
// ---------------------------------------------------------------- FakeDsh
// Mutable per-test state driving the fake executor and fake npm API.
@@ -473,6 +473,7 @@ const REGISTRY = {
{ name: 'dsh-usage-stats', owner: 'a2', url: 'https://github.com/a2/dsh-usage-stats', category: 'tool', npm: null, description: {}, install: '', added: '' },
{ name: 'dsh-blue-whale', owner: 'o', url: 'https://github.com/o/blue-whale', category: 'tool', npm: null, description: {}, install: '', added: '' },
{ name: 'dsh-patchy', owner: 'o', url: 'https://github.com/o/dsh-patchy', category: 'tool', npm: null, description: {}, install: '', added: '' },
{ name: 'dsh-crashy', owner: 'o', url: 'https://github.com/o/dsh-crashy', category: 'tool', npm: null, description: {}, install: '', added: '' },
// Carries a prebuilt Release archive (#250): its install target is a
// URL, not an npm name and not a github: shortcut.
{ name: 'dsh-prebuilt', owner: 'o', url: 'https://github.com/o/dsh-prebuilt', category: 'tool', npm: null, tarball: 'https://github.com/o/dsh-prebuilt/releases/download/v1.0.0/dsh-prebuilt.tgz', description: {}, install: '', added: '' },
@@ -3815,6 +3816,71 @@ describe('generic enable/disable toggle (#60)', () => {
expect(on.json.reason).toMatch(/cannot hot-mount|restart/)
})
it('leaves the patch layer untouched when an enable fails (#575)', async () => {
// A bundle plugin with real patch rows (the dsh-plugin-codegraph shape
// from the report: enabling it crashes deterministically on import).
fake.repos['github:o/dsh-crashy'] = {
name: 'dsh-crashy',
manifest: { dsh: { bundle: { patch: './cordis.patch.yml' } }, main: 'lib/index.js' },
artifacts: ['lib/index.js', 'cordis.patch.yml'],
}
const installed = await bed.dispatch('POST', '/dsh-market/install', { url: 'https://github.com/o/dsh-crashy' })
hot.mounts = []
const bundlePatch = join(profileDir('web'), 'node_modules', 'dsh-crashy', 'cordis.patch.yml')
mkdirSync(dirname(bundlePatch), { recursive: true })
writeFileSync(bundlePatch, "- insert:\n - id: dsh-crashy\n name: 'dsh-crashy'\n - id: dsh-crashy-tool\n name: 'dsh-crashy-tool'\n")
// Disable once: the user patch now durably holds the disabled rows.
const off = await bed.dispatch('POST', '/dsh-market/toggle', { name: 'dsh-crashy', enabled: false })
expect(off.status).toBe(200)
const patchPath = join(profileDir('web'), 'cordis.patch.yml')
const before = readFileSync(patchPath, 'utf8')
expect(before).toContain('- id: dsh-crashy\n disabled: true')
// The enable fails in-session (the deterministic import crash of #575).
// The enable fails: with every row disabled at boot the loader holds no
// entry for the plugin (the real #575 shape), so the themes path finds
// nothing and the hotMount fallback is what fails.
hot.failNext = true
const on = await bed.dispatch('POST', '/dsh-market/toggle', { name: 'dsh-crashy', enabled: true })
expect(on.status).toBe(502)
// The durable patch layer must be untouched: persisting the flipped
// rows would turn the transient in-session failure into a boot crash
// loop.
const after = readFileSync(patchPath, 'utf8')
expect(after).toBe(before)
expect(after).toContain('disabled: true')
// …and so must the market's OWN durable store. The patch layer and
// state.json are two persisted views of the same answer; leaving them
// disagreeing is worse than the original bug, because which one wins at
// the next boot depends on load order.
// …and so must the market's OWN durable answer. `disabled` in the reply
// is the same array handed to writeMarketState, so asserting it here is
// asserting what the next boot reads. Leaving the two persisted views
// disagreeing is worse than the original bug: which one wins at the next
// boot depends on load order.
expect(on.json.disabled).toContain('dsh-crashy')
})
it('a failed enable leaves a CLIENT-ONLY plugin disabled too (#575)', async () => {
// The path the patch gate cannot cover: a client-only package has no
// bundle rows, so `patchRows` is empty and the gate never runs. Its only
// durable state is state.json — which is exactly where the first version
// of this fix still wrote "enabled" after a failed mount, leaving the
// same crash loop for this kind of plugin.
await installNpm('dsh-loop', { client: './client.js' })
const off = await bed.dispatch('POST', '/dsh-market/toggle', { name: 'dsh-loop', enabled: false })
expect(off.status).toBe(200)
expect(off.json.disabled).toContain('dsh-loop')
hot.failNext = true
const on = await bed.dispatch('POST', '/dsh-market/toggle', { name: 'dsh-loop', enabled: true })
expect(on.status).toBe(502)
expect(on.json.disabled).toContain('dsh-loop')
})
it('toggles a client-only shim (dsh.client without dsh.bundle) through the hot path', async () => {
await installNpm('dsh-loop', { client: './client.js' })
expect(hot.mounts).toEqual(['dsh-loop'])