fix(recovery): don't remove plugins a deferred migration has not installed yet

When the generation migration failed (e.g. pnpm EPERM right after install),
imported plugins stayed manifest-only, failed to prepare, and plugin recovery
removed them as incompatible. Deferred migrations now report the pending
plugins and recovery excludes them. Transient EPERM/EBUSY/EACCES failures no
longer freeze the same profile for six hours, so the next launch retries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
yaojin3616
2026-09-18 10:51:37 +08:00
co-authored by Claude Opus 5
parent 8303ef0ec5
commit f475ce5979
4 changed files with 90 additions and 11 deletions
+14
View File
@@ -208,6 +208,8 @@ let webImportActionResolver: ((action: WebImportAction) => void) | undefined
let mainWindowNavigationVersion = 0
let rendererPluginFailureLogs: string[] = []
let pluginRecoveryRemovedPlugins: string[] = []
/** Plugins a deferred migration has not moved yet; their legacy copies may be manifest-only. */
let migrationPendingPlugins = new Set<string>()
let pluginRecoveryResetTimer: ReturnType<typeof setTimeout> | undefined
let pendingFrontendPluginRecovery = false
let pendingFrontendPluginRecoveryMessage: string | undefined
@@ -1448,6 +1450,11 @@ function launchHarness(): Promise<void> {
}),
reportProfileConsistency: () => reportProfileConsistency(dshHome)
})
migrationPendingPlugins = new Set(
maintenance.outcome === 'normal-profile' && maintenance.migration.outcome === 'deferred-failure'
? maintenance.migration.pendingPlugins ?? []
: []
)
if (maintenance.outcome === 'safe-recovery') {
await enterMigrationSafeRecovery(
dshHome,
@@ -1965,6 +1972,13 @@ async function showPluginRecovery(options?: {
timeoutMs: waitForRendererEvidence ? PLUGIN_RECOVERY_EVIDENCE_TIMEOUT_MS : 0
})
detection.plugins = evidence.targets(detection.plugins, removedPlugins)
// A plugin the deferred migration has not installed yet is not broken; removing
// it would delete a working plugin because of an install-time failure.
const pendingMigration = detection.plugins.filter((plugin) => migrationPendingPlugins.has(plugin))
if (pendingMigration.length > 0) {
runtime.note(`[desktop] plugin recovery: not blaming plugins still pending migration: ${pendingMigration.join(', ')}`)
detection.plugins = detection.plugins.filter((plugin) => !migrationPendingPlugins.has(plugin))
}
appendPluginRecoveryDetectionLog(detection.plugins)
if (!safeModeVisible) {
lastCrashEvidence = {
+25 -11
View File
@@ -559,6 +559,8 @@ export type MigrationOutcome =
outcome: 'deferred-failure'
reason: string
profileState: 'legacy-intact' | 'recovery-required'
/** Community plugins still waiting to move; the legacy profile may only hold their manifests. */
pendingPlugins?: string[]
}
export type MigrationRecoveryOutcome =
@@ -572,11 +574,17 @@ function noop(): MigrationOutcome {
function deferred(
reason: string,
profileState: 'legacy-intact' | 'recovery-required' = 'legacy-intact'
profileState: 'legacy-intact' | 'recovery-required' = 'legacy-intact',
pendingPlugins?: string[]
): MigrationOutcome {
return { outcome: 'deferred-failure', reason, profileState }
return pendingPlugins?.length
? { outcome: 'deferred-failure', reason, profileState, pendingPlugins }
: { outcome: 'deferred-failure', reason, profileState }
}
/** Filesystem refusals that clear on their own (security scans, delete-pending entries). */
const TRANSIENT_FILESYSTEM_ERROR = /\b(?:EPERM|EBUSY|EACCES)\b/
function migrated(): MigrationOutcome {
return { outcome: 'migrated' }
}
@@ -632,15 +640,15 @@ export async function migrateProfileToGenerations(deps: MigrationDeps): Promise<
const fingerprint = await migrationInputFingerprint(dshHome, plugins)
if (await readDeferredFingerprint(dshHome) === fingerprint) {
note('[desktop] migration deferred: this exact profile already failed preflight')
return deferred(reason)
return deferred(reason, 'legacy-intact', plugins)
}
note(`[desktop] migration deferred before staging: ${reason}`)
await writeDeferred(dshHome, fingerprint, reason).catch(() => undefined)
return deferred(reason)
return deferred(reason, 'legacy-intact', plugins)
}
if (await readDeferredFingerprint(dshHome) === plan.fingerprint) {
note('[desktop] migration deferred: this exact profile already failed preflight')
return deferred('previously failed preflight for this exact fingerprint')
return deferred('previously failed preflight for this exact fingerprint', 'legacy-intact', plugins)
}
note(`[desktop] migration: moving ${plugins.length} plugin(s) to generations: ${plugins.join(', ')}`)
@@ -653,7 +661,7 @@ export async function migrateProfileToGenerations(deps: MigrationDeps): Promise<
}`
note(`[desktop] migration deferred before staging: ${reason}`)
await writeDeferred(dshHome, plan.fingerprint, reason).catch(() => undefined)
return deferred(reason)
return deferred(reason, 'legacy-intact', plugins)
}
try {
const generationIds: string[] = []
@@ -723,18 +731,22 @@ export async function migrateProfileToGenerations(deps: MigrationDeps): Promise<
snapshotError instanceof Error ? snapshotError.message : String(snapshotError)
}`
note(`[desktop] migration recovery required: ${combined}`)
return deferred(combined, 'recovery-required')
return deferred(combined, 'recovery-required', plugins)
}
if (snapshot !== undefined) {
const rollback = await rollBackMigration(dshHome, note, reason)
if (rollback.outcome === 'recovery-required') {
const combined = `${reason}; ${rollback.reason}`
await writeDeferred(dshHome, plan.fingerprint, combined).catch(() => undefined)
return deferred(combined, 'recovery-required')
return deferred(combined, 'recovery-required', plugins)
}
}
await writeDeferred(dshHome, plan.fingerprint, reason).catch(() => undefined)
return deferred(reason)
// A transient refusal must not freeze this exact profile for hours: its
// plugins are only manifests until the move succeeds, so retry next launch.
if (!TRANSIENT_FILESYSTEM_ERROR.test(reason)) {
await writeDeferred(dshHome, plan.fingerprint, reason).catch(() => undefined)
}
return deferred(reason, 'legacy-intact', plugins)
}
}
@@ -972,7 +984,9 @@ export async function rollBackMigration(
const plugins = await communityPlugins(dshHome)
state.fingerprint = await migrationInputFingerprint(dshHome, plugins)
}
await writeDeferred(dshHome, state.fingerprint, failureReason)
if (!TRANSIENT_FILESYSTEM_ERROR.test(failureReason)) {
await writeDeferred(dshHome, state.fingerprint, failureReason)
}
state.phase = 'rollback-cleanup'
await writeSnapshotState(dshHome, state)
} catch (error) {
+36
View File
@@ -18,6 +18,7 @@ import {
} from '../packages/dsh-desktop-market-installer/generations/registry'
const installCalls: string[] = []
let failingInstallOutput: string | undefined
// The installer's pnpm step is stubbed via a module mock so the migration's
// generation installs run offline.
@@ -40,6 +41,7 @@ vi.mock('dsh-desktop-market-installer/generations/installer', async () => {
return actual.installGeneration({
...options,
runInstall: async (stagingDir: string) => {
if (failingInstallOutput !== undefined) return { code: 1, output: failingInstallOutput }
const pkg = join(stagingDir, 'node_modules', name)
await mkdir(pkg, { recursive: true })
await writeFile(
@@ -121,6 +123,40 @@ describe('one-time profile migration to generations', () => {
await Promise.all(homes.map((home) => rm(home, { recursive: true, force: true })))
homes.length = 0
installCalls.length = 0
failingInstallOutput = undefined
})
it('reports the plugins still pending and retries a transient filesystem refusal on the next launch', async () => {
const home = await preUpgradeProfile({ 'plugin-one': '1.0.0', 'plugin-two': '2.0.0' })
failingInstallOutput = "EPERM: operation not permitted, symlink 'staging' -> 'store'"
const first = await migrateProfileToGenerations(deps(home))
expect(first).toMatchObject({
outcome: 'deferred-failure',
profileState: 'legacy-intact',
pendingPlugins: ['plugin-one', 'plugin-two']
})
const attempts = installCalls.length
failingInstallOutput = undefined
expect(await migrateProfileToGenerations(deps(home))).toEqual({ outcome: 'migrated' })
expect(installCalls.length).toBeGreaterThan(attempts)
})
it('still freezes an identical profile after a non-transient install failure', async () => {
const home = await preUpgradeProfile({ 'plugin-one': '1.0.0' })
failingInstallOutput = 'ERR_PNPM_NO_MATCHING_VERSION No matching version found'
const first = await migrateProfileToGenerations(deps(home))
expect(first).toMatchObject({ outcome: 'deferred-failure', pendingPlugins: ['plugin-one'] })
const attempts = installCalls.length
failingInstallOutput = undefined
expect(await migrateProfileToGenerations(deps(home))).toMatchObject({
outcome: 'deferred-failure',
pendingPlugins: ['plugin-one']
})
expect(installCalls).toHaveLength(attempts)
})
it('moves community plugins to generations and trims the manifest', async () => {
+15
View File
@@ -75,6 +75,21 @@ describe('preload wiring for plugin error handling', () => {
expect(handlerBody).toContain('desktopDiagnostics?.discardPendingPluginFailure()')
})
it('never offers removal of plugins a deferred migration has not installed yet', async () => {
const main = await readFile('src/main/index.ts', 'utf8')
const maintenance = main.slice(main.indexOf('const maintenance = await runProfileStartupMaintenance('))
expect(maintenance.slice(0, maintenance.indexOf("if (maintenance.outcome === 'safe-recovery')"))).toContain(
'maintenance.migration.pendingPlugins'
)
const loop = main.slice(main.indexOf('const detection = await detectPluginRecovery('))
const beforeAction = loop.slice(0, loop.indexOf('waitForPluginRecoveryAction('))
const filter = beforeAction.indexOf('!migrationPendingPlugins.has(plugin)')
expect(filter).toBeGreaterThan(-1)
expect(filter).toBeLessThan(beforeAction.indexOf('appendPluginRecoveryDetectionLog(detection.plugins)'))
expect(filter).toBeLessThan(beforeAction.indexOf('discardPendingPluginFailure()'))
})
it('discards pending diagnostics when Harness startup failure identifies a plugin', async () => {
const main = await readFile('src/main/index.ts', 'utf8')
const loop = main.slice(main.indexOf('const detection = await detectPluginRecovery('))