test(ai): replaced busy-handler ordering spy with real lock contention

- The #7298 test asserted PRAGMA-vs-DDL ordering by spying on
  Database.prototype.run SQL strings — implementation plumbing.
- Now a child process creates the db and holds BEGIN EXCLUSIVE for 750ms
  (past open()'s ~700ms retry budget, under the 1000ms headless
  busy_timeout), signaling readiness via a filesystem sentinel; open()
  only survives if the busy handler is installed before the leases DDL.
- Verified the test fails with the ordering fix reverted.
This commit is contained in:
can1357
2026-08-01 21:42:05 +02:00
parent 3e978e95c0
commit 3b84fde884
@@ -81,31 +81,61 @@ describe("SqliteAuthCredentialStore.open SQLITE_BUSY handling", () => {
}
});
test("installs the busy handler before the open()-path leases DDL (#7298)", async () => {
test("open() survives a writer holding the lock past the retry budget (#7298)", async () => {
const dbPath = path.join(tempDir, "ordering.db");
const realRun = Database.prototype.run;
const executed: string[] = [];
vi.spyOn(Database.prototype, "run").mockImplementation(function (
this: Database,
...args: Parameters<typeof realRun>
) {
executed.push(typeof args[0] === "string" ? args[0] : "");
return realRun.apply(this, args);
});
const sentinel = path.join(tempDir, "locked.sentinel");
// The child creates the (empty) database and holds an EXCLUSIVE lock for
// 750ms: longer than open()'s full retry budget (attempts at
// ~0/100/300/700ms), shorter than the headless 1000ms busy_timeout. The
// empty file forces open()'s leases DDL to take the write lock, so only
// a busy handler installed BEFORE that DDL lets the first attempt wait
// out the writer; the pre-fix code (busy_timeout=0 during the DDL)
// exhausted its retries and threw. Readiness is signaled via a sentinel
// file written after BEGIN EXCLUSIVE — not stdout — so the handshake
// cannot be affected by how the runner wires child stdio.
const locker = Bun.spawn(
[
process.execPath,
"-e",
`import { Database } from "bun:sqlite";
import { writeFileSync } from "node:fs";
const db = new Database(process.argv[1]);
db.run("BEGIN EXCLUSIVE");
writeFileSync(process.argv[2], "locked");
await Bun.sleep(750);
db.run("COMMIT");
db.close();`,
dbPath,
sentinel,
],
{ env: { HOME: process.env.HOME ?? "", PATH: process.env.PATH ?? "" }, stdout: "ignore", stderr: "pipe" },
);
// Real-time waits are unavoidable here: the lock lives in a separate
// process, so fake timers cannot advance the child's sleep or the
// filesystem sentinel it writes.
const deadline = Date.now() + 5000;
let locked = false;
while (Date.now() < deadline) {
locked = await fs.access(sentinel).then(
() => true,
() => false,
);
if (locked || locker.exitCode !== null) break;
await Bun.sleep(10);
}
if (!locked) {
throw new Error(`locker never signaled readiness: ${await new Response(locker.stderr).text()}`);
}
const store = await SqliteAuthCredentialStore.open(dbPath);
try {
const busyIdx = executed.findIndex(sql => /busy_timeout/i.test(sql));
const leasesIdx = executed.findIndex(sql => /auth_credential_refresh_leases/i.test(sql));
expect(busyIdx).toBeGreaterThanOrEqual(0);
expect(leasesIdx).toBeGreaterThanOrEqual(0);
// `open()` runs the leases DDL before constructing the store. The busy
// handler MUST be installed first, or that lock-taking DDL races WAL
// recovery with busy_timeout=0 and fails immediately.
expect(busyIdx).toBeLessThan(leasesIdx);
// The store is fully usable after contention: the leases DDL ran.
expect(store.listAuthCredentials()).toEqual([]);
} finally {
store.close();
}
const [exitCode, stderr] = await Promise.all([locker.exited, new Response(locker.stderr).text()]);
expect(exitCode, stderr).toBe(0);
});
test("retries through a transient SQLITE_BUSY_RECOVERY and eventually succeeds", async () => {