Skip to content
Open
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
9 changes: 8 additions & 1 deletion src/db-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,14 @@ export function loadDatabase(): typeof DatabaseConstructor {
readonly: opts?.readonly,
create: true,
});
return new BunSQLiteAdapter(raw);
const adapter = new BunSQLiteAdapter(raw);
// Propagate busy_timeout to match better-sqlite3's timeout option.
// Without this, concurrent writes under Bun fail immediately with
// SQLITE_BUSY instead of retrying (better-sqlite3 sets this automatically).
if (opts?.timeout) {
adapter.pragma(`busy_timeout = ${Number(opts.timeout)}`);
}
return adapter;
} as any;
} else {
// Node.js — use better-sqlite3.
Expand Down
7 changes: 6 additions & 1 deletion src/session/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,12 @@ export class SessionDB extends SQLiteBase {
this.stmt(S.updateMetaLastEvent).run(sessionId);
});

transaction();
// Use withRetry to handle SQLITE_BUSY under concurrent PostToolUse hooks.
// When many tool calls complete in parallel (e.g., batch get_issue), multiple
// hook processes compete for the write lock on the same SessionDB file.
// better-sqlite3's busy_timeout handles most cases, but withRetry provides
// defense-in-depth for edge cases like lock escalation during transactions.
this.withRetry(() => transaction());
}

/**
Expand Down
38 changes: 38 additions & 0 deletions tests/session/session-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,41 @@ describe("Limit", () => {
assert.equal(limited[2].data, "file-2.ts");
});
});

// ════════════════════════════════════════════
// ADDITIONAL: Concurrent insert resilience
// ════════════════════════════════════════════

describe("Concurrent Insert Resilience", () => {
test("multiple DB instances can write to the same file without data loss", () => {
// Simulates concurrent PostToolUse hooks writing to the same SessionDB.
// When many tool calls complete in parallel (e.g., batch get_issue),
// multiple hook processes open the same DB and insert events concurrently.
const dbPath = join(tmpdir(), `session-concurrent-${randomUUID()}.db`);
const instances = Array.from({ length: 5 }, () => {
const db = new SessionDB({ dbPath });
cleanups.push(() => db.cleanup());
return db;
});

const sid = "sess-concurrent";
instances[0].ensureSession(sid, "/project");

// Each instance inserts unique events
for (let i = 0; i < instances.length; i++) {
instances[i].insertEvent(sid, makeEvent({
type: "mcp",
data: `get_issue: UXF-${i}`,
}));
}

// All events should be present — no SQLITE_BUSY failures
const events = instances[0].getEvents(sid);
assert.equal(events.length, 5, `Expected 5 events from concurrent inserts, got ${events.length}`);

// Clean up all instances
for (const db of instances) {
db.close();
}
});
});
Loading