fix(tui): close resume handoff races

This commit is contained in:
NI0317
2026-07-24 12:58:53 +08:00
committed by ZiyaZhang
parent 2ae9f4fdf3
commit 54d986ed87
20 changed files with 375 additions and 76 deletions
@@ -57,3 +57,4 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it.
@@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
sessionLeaseProcessIsLive, shareSessionLiveLease,
sessionLeaseOwnerIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
@@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const current = this.liveLeaseFor(id)
if (current !== undefined
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
if (sessionLeaseProcessIsLive(current.pid)) {
if (sessionLeaseOwnerIsLive(current, owner)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
@@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const current = this.liveLeaseFor(id)
if (current === undefined) return false
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|| sessionLeaseProcessIsLive(current.pid)) return true
|| sessionLeaseOwnerIsLive(current, owner)) return true
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?')
.run(id, current.pid, current.nonce)
return false
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
@@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
afterEach(async () => {
vi.restoreAllMocks()
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
@@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await b.ctx.sessionPersistence.list()
const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite
const owner = sessionLiveOwner()
const occupiedPid = process.pid + 1
const originalKill = process.kill.bind(process)
vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid === occupiedPid) return true
return originalKill(pid, signal)
})
const db = openDatabase(path, 'wal')
const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)')
insert.run('occupied-lease', process.pid, 'another-owner')
insert.run('occupied-lease', occupiedPid, 'another-owner')
insert.run('reused-pid', process.pid, 'prior-incarnation')
insert.run('stale-claim', 2_147_483_647, 'dead-owner')
insert.run('stale-inspect', 2_147_483_647, 'dead-owner')
insert.run('owned-inspect', owner.pid, owner.nonce)
@@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await expect(concrete.acquireLive(SessionId('occupied-lease'), owner))
.rejects.toThrow('occupied by another live process')
const reused = await concrete.acquireLive(SessionId('reused-pid'), owner)
const claim = await concrete.acquireLive(SessionId('stale-claim'), owner)
expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true)
expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false)
expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false)
await claim()
await reused()
await b.dispose()
const memory = new Context()