fix: prevent failed session deletes from poisoning chat startup

Deleting a desktop session marks it unavailable before removing the
underlying session file. If that file removal fails, the session can still
exist on disk while future startup attempts are rejected as deleted. Roll
back the deleted marker on delete failure so the session remains usable and
the original delete error stays visible.

Constraint: Windows desktop users can hit file deletion failures while the session is still visible after refresh
Rejected: Delay marking until after deletion | would allow concurrent startup during an in-flight delete
Confidence: high
Scope-risk: narrow
Directive: Keep the temporary deleted marker during active deletes, but always rollback on failed persistence operations
Tested: Real DeepSeek provider call with issue 259 repro path
Tested: bun test src/server/__tests__/sessions.test.ts --timeout 20000
Tested: bun test src/server/__tests__/conversations.test.ts --timeout 30000
Tested: bun run check:server
Tested: bun run check:native
Tested: bun run quality:pr
Not-tested: Native Windows filesystem lock behavior on an actual Windows host
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 15:25:01 +08:00
parent e99c567dea
commit 0fe567439b
3 changed files with 37 additions and 1 deletions

View File

@ -8,6 +8,8 @@ import { execFileSync } from 'node:child_process'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { sessionService } from '../services/sessionService.js'
import { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
@ -1161,6 +1163,31 @@ describe('Sessions API', () => {
expect(res2.status).toBe(404)
})
it('DELETE /api/sessions/:id should roll back the deleted marker when file deletion fails', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
const originalDeleteSession = sessionService.deleteSession.bind(sessionService)
sessionService.deleteSession = (async (targetSessionId: string) => {
if (targetSessionId === sessionId) {
throw new Error('simulated unlink failure')
}
return originalDeleteSession(targetSessionId)
}) as typeof sessionService.deleteSession
try {
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
expect(res.status).toBe(500)
expect((conversationService as any).deletedSessions.has(sessionId)).toBe(false)
const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
expect(detailRes.status).toBe(200)
} finally {
sessionService.deleteSession = originalDeleteSession as typeof sessionService.deleteSession
conversationService.unmarkSessionDeleted(sessionId)
}
})
it('PATCH /api/sessions/:id should rename the session', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-api-test', sessionId, [

View File

@ -316,7 +316,12 @@ function isSessionNotFoundError(error: unknown): error is Error {
async function deleteSession(sessionId: string): Promise<Response> {
conversationService.markSessionDeleted(sessionId)
await sessionService.deleteSession(sessionId)
try {
await sessionService.deleteSession(sessionId)
} catch (error) {
conversationService.unmarkSessionDeleted(sessionId)
throw error
}
return Response.json({ ok: true })
}

View File

@ -545,6 +545,10 @@ export class ConversationService {
this.stopSession(sessionId)
}
unmarkSessionDeleted(sessionId: string): void {
this.deletedSessions.delete(sessionId)
}
getActiveSessions(): string[] {
return Array.from(this.sessions.keys())
}