mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
Prevent IM chats from reusing deleted desktop sessions
Deleting a desktop session removed the transcript but left IM adapter chat mappings and live WebSocket state able to point at the old session. The server now closes the active session socket and removes adapter mappings after deletion, while adapters refresh the shared session store before reads so a running process cannot reuse stale in-memory data. Constraint: Adapter session mappings are shared through adapter-sessions.json across long-running IM processes Rejected: Patch each adapter ensureSession path separately | shared store refresh fixes all current adapters and avoids drift Confidence: high Scope-risk: moderate Directive: Do not cache adapter session mappings without invalidating after server-side session deletion Tested: bun test common/__tests__/session-store.test.ts common/__tests__/ws-bridge.test.ts Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/sessions.test.ts --timeout 30000 Tested: cd adapters && bunx tsc --noEmit Tested: bun run check:adapters Tested: bun run check:server Not-tested: bun run verify fails on pre-existing agent-utils coverage ratchet; changed-line coverage is 95.92% and affected lanes pass Related: https://github.com/NanmiCoder/cc-haha/issues/305
This commit is contained in:
@@ -41,6 +41,41 @@ describe('SessionStore', () => {
|
||||
expect(store.get('chat-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes every chat entry bound to a sessionId', () => {
|
||||
store.set('chat-1', 'uuid-shared', '/project-a')
|
||||
store.set('chat-2', 'uuid-other', '/project-b')
|
||||
store.set('chat-3', 'uuid-shared', '/project-c')
|
||||
|
||||
const removed = store.deleteBySessionId('uuid-shared')
|
||||
|
||||
expect(removed.sort()).toEqual(['chat-1', 'chat-3'])
|
||||
expect(store.get('chat-1')).toBeNull()
|
||||
expect(store.get('chat-3')).toBeNull()
|
||||
expect(store.get('chat-2')!.sessionId).toBe('uuid-other')
|
||||
|
||||
const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
expect(reloaded.get('chat-1')).toBeNull()
|
||||
expect(reloaded.get('chat-3')).toBeNull()
|
||||
expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other')
|
||||
})
|
||||
|
||||
it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => {
|
||||
store.set('chat-1', 'uuid-stale', '/project')
|
||||
const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
|
||||
expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1'])
|
||||
|
||||
expect(store.get('chat-1')).toBeNull()
|
||||
expect(store.listAll()).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when deleting an unknown sessionId', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/project')
|
||||
|
||||
expect(store.deleteBySessionId('uuid-missing')).toEqual([])
|
||||
expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
||||
})
|
||||
|
||||
it('persists to disk and reloads', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path')
|
||||
|
||||
|
||||
@@ -163,6 +163,24 @@ describe('WsBridge: handler serialization', () => {
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
it('forgets a chat when the server closes the session normally', async () => {
|
||||
const bridge = new WsBridge(serverUrl, 'test')
|
||||
bridge.onServerMessage('chat-deleted', () => {})
|
||||
bridge.connectSession('chat-deleted', 'sess-deleted')
|
||||
await bridge.waitForOpen('chat-deleted')
|
||||
|
||||
const serverWs = connections[0]!
|
||||
serverWs.close(1000, 'session deleted')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(bridge.hasSession('chat-deleted')).toBe(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
||||
expect(connections).toHaveLength(1)
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
it('resetSession clears the handler chain', async () => {
|
||||
const bridge = new WsBridge(serverUrl, 'test')
|
||||
bridge.onServerMessage('chat-reset', () => {})
|
||||
|
||||
@@ -25,23 +25,45 @@ export class SessionStore {
|
||||
}
|
||||
|
||||
get(chatId: string): SessionEntry | null {
|
||||
this.refresh()
|
||||
return this.data[chatId] ?? null
|
||||
}
|
||||
|
||||
set(chatId: string, sessionId: string, workDir: string): void {
|
||||
this.refresh()
|
||||
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
|
||||
this.save()
|
||||
}
|
||||
|
||||
delete(chatId: string): void {
|
||||
this.refresh()
|
||||
delete this.data[chatId]
|
||||
this.save()
|
||||
}
|
||||
|
||||
deleteBySessionId(sessionId: string): string[] {
|
||||
this.refresh()
|
||||
const removed: string[] = []
|
||||
for (const [chatId, entry] of Object.entries(this.data)) {
|
||||
if (entry.sessionId !== sessionId) continue
|
||||
delete this.data[chatId]
|
||||
removed.push(chatId)
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
this.save()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
listAll(): Array<{ chatId: string } & SessionEntry> {
|
||||
this.refresh()
|
||||
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
this.data = this.load()
|
||||
}
|
||||
|
||||
private load(): StoreData {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
|
||||
|
||||
@@ -201,7 +201,14 @@ export class WsBridge {
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
|
||||
if (code === 1000) return
|
||||
if (this.sessions.get(chatId) !== session) return
|
||||
if (code === 1000) {
|
||||
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
||||
this.sessions.delete(chatId)
|
||||
this.handlers.delete(chatId)
|
||||
this.handlerChains.delete(chatId)
|
||||
return
|
||||
}
|
||||
this.scheduleReconnect(chatId, sessionId)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user