Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions __tests__/sessions-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../src/sessions/claude-code';
import {
SessionsIndex,
enterWalMode,
ftsQuery,
querySessions,
sessionsSourceDir,
Expand Down Expand Up @@ -204,6 +205,59 @@ describe('SessionsIndex', () => {
expect(index.search('pool transcript threads')).toHaveLength(1);
index.close();
});

it('opens a fresh database while another connection holds it, converting to WAL once free', async () => {
// An ordinary lock wait on the conversion is covered by busy_timeout: the
// holder commits and this open then converts, rather than throwing.
const dbPath = path.join(fixtureDir(), 'sessions.db');
const other = new Worker(
`const { workerData, parentPort } = require('worker_threads');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.dbPath);
db.exec('BEGIN EXCLUSIVE');
parentPort.postMessage('locked');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
db.exec('COMMIT');
db.close();`,
{ eval: true, workerData: { dbPath } },
);
await new Promise((resolve) => other.once('message', resolve));
const index = SessionsIndex.open(dbPath);
await new Promise((resolve) => other.once('exit', resolve));
const db = createDatabase(dbPath).db;
expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal');
db.close();
index.close();
});

it('retries a WAL conversion that collides with another process converting the same fresh file', () => {
// What busy_timeout does NOT cover: several processes converting one
// brand-new database at the same moment collide inside the conversion
// rather than queueing on a lock, and it surfaces as either of these two
// transient errors. That collision only reproduces probabilistically, so
// the retry is driven directly here.
for (const message of ['database is locked', 'disk I/O error']) {
let calls = 0;
const db = {
pragma(sql: string) {
if (!/journal_mode\s*=/i.test(sql)) return 'delete';
if (++calls < 3) throw new Error(message);
return 'wal';
},
};
expect(() => enterWalMode(db as never)).not.toThrow();
expect(calls).toBe(3);
}
});

it('gives up on a WAL conversion error that is not the transient collision', () => {
const db = {
pragma() {
throw new Error('unable to open database file');
},
};
expect(() => enterWalMode(db as never)).toThrow(/unable to open database file/);
});
});

describe('querySessions (project entry point)', () => {
Expand Down
36 changes: 35 additions & 1 deletion src/sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ export const BUSY_TIMEOUT_MS = 5000;
/** Bump when the readers' notion of prose changes, so existing indexes rebuild. */
const INDEX_VERSION = 2;

/**
* `busy_timeout` does cover an ordinary lock wait on this pragma: a connection
* that merely holds the database is waited out and the conversion then
* succeeds. What it does not cover is several processes converting the SAME
* brand-new database at the same moment — they collide inside the conversion
* itself rather than queueing on a lock. That is only ever the first run: WAL
* is persistent, so once the file is in WAL nobody converts it again.
*
* Both errors that collision raises are transient and clear on their own.
* `database is locked` is the conversion losing the race; `disk I/O error` is
* the shared-memory `-shm` file being created underneath a concurrent opener,
* seen on Windows. Retrying either inside the existing budget is enough.
* Tolerating a failed conversion is not an option: the connection does not
* survive one, and the next statement on it fails too.
*
* Exported for the test that drives the retry directly — the collision itself
* only reproduces probabilistically, so the retry is asserted here instead.
*/
export function enterWalMode(db: SqliteDatabase): void {
const deadline = Date.now() + BUSY_TIMEOUT_MS;
for (;;) {
try {
db.pragma('journal_mode = WAL');
return;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const transient = /database is locked|database is busy|disk i\/o error/i.test(message);
if (!transient || Date.now() >= deadline) throw err;
// Jittered, so the losers of one collision do not retry in lockstep.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 + Math.random() * 20);
}
}
}

export interface SessionsIndexStats {
/** Transcript files seen. */
files: number;
Expand Down Expand Up @@ -122,7 +156,7 @@ export class SessionsIndex {
// of waiting the few hundred milliseconds the winner's write takes. Set
// before the constructor's schema and version writes, which race the same way.
db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
if (dbPath !== ':memory:') db.pragma('journal_mode = WAL');
if (dbPath !== ':memory:') enterWalMode(db);
return new SessionsIndex(db);
}

Expand Down