From 0fe567439bf88e40ddd45ae18197ded540c15e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 15:25:01 +0800 Subject: [PATCH] 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 --- src/server/__tests__/sessions.test.ts | 27 ++++++++++++++++++++++ src/server/api/sessions.ts | 7 +++++- src/server/services/conversationService.ts | 4 ++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 040009ba..38a56d4c 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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, [ diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 0c940c04..3b2fed64 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -316,7 +316,12 @@ function isSessionNotFoundError(error: unknown): error is Error { async function deleteSession(sessionId: string): Promise { conversationService.markSessionDeleted(sessionId) - await sessionService.deleteSession(sessionId) + try { + await sessionService.deleteSession(sessionId) + } catch (error) { + conversationService.unmarkSessionDeleted(sessionId) + throw error + } return Response.json({ ok: true }) } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index f7a20640..8457d508 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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()) }