Where does the bug appear (feature/product)?
Cursor SDK
Describe the Bug
Note: this is about the @cursor/sdk npm package (v1.0.23), not the Cursor IDE — so the IDE/model/Request-ID fields below don’t apply.
All three SQLite stores in @cursor/sdk run a single multi-statement init exec() in which PRAGMA busy_timeout is the third statement, after PRAGMA journal_mode = WAL.
Switching to WAL creates/recovers the -shm file and needs a brief exclusive lock — and it runs before the busy handler is armed. Two processes opening the same store at the same time therefore fail immediately with database is locked instead of waiting out the configured 5s timeout.
Affected chunks (minified dist), each containing the ordering verbatim:
dist/esm/656.js— run-event store (get dbPath(){return join(this.options.stateRoot,"index.db")})dist/esm/18.js— blob storedist/esm/856.js— agent/run store
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
Driver selection is inlined into those same chunks (openBunSqliteDriver when globalThis.Bun is defined, else openNodeSqliteDriver via node:sqlite). Both driver paths execute the same init string, so both are affected — though I only measured the node:sqlite path.
Steps to Reproduce
Self-contained, no dependencies beyond Node >= 22.13. It seeds a WAL database, closes it cleanly (SQLite then removes -wal/-shm, which is why a cold open is the common case), asserts those files are absent, then releases N pre-forked processes simultaneously, each running the SDK’s exact init string.
Note on N: at N=8 I saw 0 failures across 40 opens. Reproducing this needs enough simultaneous openers — N=32 worked reliably here. Unsynchronized fork() also staggers children enough to miss the window entirely, hence the explicit “go” release below.
// Run: REPRO_N=32 REPRO_ROUNDS=8 node cursor-sdk-repro.mjs (Node >= 22.13, no deps)
import { DatabaseSync } from 'node:sqlite';
import { fork } from 'node:child_process';
import { mkdtempSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Pragma order as shipped in @cursor/[email protected] dist/esm/{656,18,856}.js
const INIT_BAD = `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS run_events (run_id TEXT PRIMARY KEY, payload_json TEXT);
`;
// Proposed fix: busy_timeout first.
const INIT_FIXED = `
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS run_events (run_id TEXT PRIMARY KEY, payload_json TEXT);
`;
const dbPath = process.env.REPRO_DB;
if (process.env.REPRO_ROLE === 'racer') {
const init = process.env.REPRO_INIT === 'fixed' ? INIT_FIXED : INIT_BAD;
// Children are pre-forked and idle; they all open only on the "go" message,
// so the opens actually overlap inside the WAL-recovery window.
process.send?.({ ready: true });
process.on('message', (m) => {
if (m !== 'go') return;
const t0 = Date.now();
try {
const db = new DatabaseSync(dbPath);
db.exec(init);
db.close();
process.send?.({ ok: true, ms: Date.now() - t0 });
} catch (e) {
process.send?.({ ok: false, ms: Date.now() - t0, msg: e.message });
}
process.exit(0);
});
}
if (process.env.REPRO_ROLE === 'writer') {
// Already-initialized db: busy handler IS armed before the contended write.
const db = new DatabaseSync(dbPath);
db.exec(INIT_BAD);
const t0 = Date.now();
try {
db.exec('BEGIN IMMEDIATE');
process.send?.({ ok: true, ms: Date.now() - t0 });
} catch (e) {
process.send?.({ ok: false, ms: Date.now() - t0, msg: e.message });
}
process.exit(0);
}
if (process.env.REPRO_ROLE) {
// child: idle until its "go" message arrives
} else {
const dir = mkdtempSync(join(tmpdir(), 'cursor-sdk-repro-'));
const db = join(dir, 'index.db');
const self = new URL(import.meta.url).pathname;
const spawn = (role, init) =>
new Promise((res) => {
const c = fork(self, {
env: { ...process.env, REPRO_ROLE: role, REPRO_DB: db, REPRO_INIT: init },
});
let r = null;
c.on('message', (m) => (r = m));
c.on('exit', () => res({ pid: c.pid, ...r }));
});
function seed() {
rmSync(db, { force: true });
rmSync(db + '-wal', { force: true });
rmSync(db + '-shm', { force: true });
const d = new DatabaseSync(db);
d.exec(INIT_BAD);
d.exec("INSERT INTO run_events VALUES ('r1','{}')");
d.close(); // clean close: SQLite removes -wal/-shm
}
const N = Number(process.env.REPRO_N ?? 8);
// Pre-fork idle racers, then release them all at once.
function preforkRacers(init) {
return Array.from({ length: N }, () => {
const c = fork(self, {
env: { ...process.env, REPRO_ROLE: 'racer', REPRO_DB: db, REPRO_INIT: init },
});
const p = new Promise((res) => {
let r = null;
c.on('message', (m) => {
if (m?.ready) return;
r = m;
});
c.on('exit', () => res({ pid: c.pid, ...r }));
});
const ready = new Promise((res) => c.once('message', res));
return { c, p, ready };
});
}
async function race(init) {
let fails = 0;
for (let round = 1; round <= Number(process.env.REPRO_ROUNDS ?? 5); round++) {
seed();
// Cold open is the common case precisely because -wal/-shm are gone:
const cold = !existsSync(db + '-wal') && !existsSync(db + '-shm');
const racers = preforkRacers(init);
await Promise.all(racers.map((r) => r.ready));
for (const r of racers) r.c.send('go');
const rs = await Promise.all(racers.map((r) => r.p));
const bad = rs.filter((r) => !r.ok);
fails += bad.length;
console.log(
` round ${round} (cold=${cold}): ${bad.length}/${N} failed` +
bad.map((b) => `\n pid ${b.pid} FAILED at init-exec after ${b.ms}ms: ${b.msg}`).join(''),
);
}
return fails;
}
console.log(`node ${process.version} | ${process.platform} | ${N} racers x rounds\n`);
console.log('A) shipped pragma order (journal_mode before busy_timeout):');
console.log(` => ${await race('bad')} total failures\n`);
console.log('B) proposed fix (busy_timeout first):');
console.log(` => ${await race('fixed')} total failures\n`);
// C) Contrast: once the handler IS armed, real contention waits it out.
console.log('C) contended write with busy handler already armed:');
seed();
const holder = new DatabaseSync(db);
holder.exec(INIT_BAD);
holder.exec('BEGIN EXCLUSIVE');
const w = await spawn('writer');
console.log(
` writer ${w.ok ? 'acquired' : 'FAILED'} after ${w.ms}ms${w.msg ? `: ${w.msg}` : ''}`,
);
holder.exec('ROLLBACK');
holder.close();
rmSync(dir, { recursive: true, force: true });
}
Results
REPRO_N=32 REPRO_ROUNDS=8, 256 opens per arm:
node v24.12.0 | darwin | 32 racers x rounds
A) shipped pragma order (journal_mode before busy_timeout):
...
round 5 (cold=true): 2/32 failed
pid 95354 FAILED at init-exec after 5ms: database is locked
pid 95362 FAILED at init-exec after 5ms: database is locked
round 6 (cold=true): 0/32 failed
round 7 (cold=true): 1/32 failed
pid 95401 FAILED at init-exec after 0ms: database is locked
round 8 (cold=true): 2/32 failed
pid 95466 FAILED at init-exec after 18ms: database is locked
pid 95468 FAILED at init-exec after 1ms: database is locked
=> 19 total failures
B) proposed fix (busy_timeout first):
round 1..8 (cold=true): 0/32 failed
=> 0 total failures
C) contended write with busy handler already armed:
writer FAILED after 5196ms: database is locked
Arm B — the identical race with only the pragma order changed — is clean across all 256 opens. Per-round failures in arm A were 1, 3, 4, 6, 2, 0, 1, 2, i.e. intermittent as a race should be.
Expected Behavior
PRAGMA busy_timeout = 5000 should apply to the WAL/-shm setup on a cold open, so a concurrent opener waits (up to 5s) rather than failing instantly.
The contrast below is the argument: failures land at 0–18 ms, so the busy handler plainly isn’t in effect yet. When the handler is already armed and a genuinely contended write happens, SQLite correctly waits 5196 ms before giving up. So this isn’t ordinary contention being reported honestly — it’s the pre-arming journal_mode statement failing with no backoff at all.
Proposed fix — move PRAGMA busy_timeout to the front of the init string, in all three stores:
-PRAGMA journal_mode = WAL;
-PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
busy_timeout is connection-scoped and takes no locks, so it’s safe as the first statement and arms the handler before anything that can contend.
Operating System
MacOS
Version Information
Not a Cursor IDE/CLI issue — this is the @cursor/sdk npm package.
@cursor/sdk: 1.0.23 (latest on npm as of 2026-07-20; dist-tags.latest = 1.0.23 — the ordering is NOT fixed in any newer release)
Node: v24.12.0
OS: macOS 26.5.1, arm64
Driver exercised: node:sqlite (openNodeSqliteDriver path)
For AI issues: which model did you use?
N/A — not model-related. This is a SQLite init defect in the @cursor/sdk package.
For AI issues: add Request ID with privacy disabled
N/A — no Cursor request involved; this is a local SQLite init race in the @cursor/sdk npm package.
Additional Information
Impact
Any host running more than one agent concurrently against a single project store. Cold opens are the normal case, not an edge case, precisely because SQLite deletes -wal/-shm when the last connection closes — so a store that was cleanly shut down is exactly the one that races on next startup.
The failure surfaces as an immediate database is locked at SDK init rather than a timeout, which makes it look like corruption rather than contention. In our case it killed an unattended scheduled job outright.
Not verified / caveats
- I did not run the repro under Bun, so the
openBunSqliteDriverpath is affected by inspection of the shared init string, not by measurement. - The repro drives
node:sqlitedirectly with the SDK’s init string rather than instantiating the SDK’s store classes; it reproduces the pragma-ordering race, not the full SDK code path. - I did not check whether earlier versions (1.0.17–1.0.22) carry the same ordering; only 1.0.23 was inspected.
- Failure rate is timing- and machine-dependent; the absolute counts above will not transfer to other hardware.
Does this stop you from using Cursor
No - Cursor works, but with this issue