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
69 changes: 68 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.12
bun-version: 1.4.1

- name: Install extraction tools
run: sudo apt-get update && sudo apt-get install -y p7zip-full
Expand Down Expand Up @@ -59,3 +59,70 @@ jobs:
npm pkg fix --dry-run --json
git diff --check
git diff --exit-code -- package.json zcode-runtime.lock.json

# Transfer the exact validated package, preserving executable bits in
# its tarball. Matrix jobs must not extract a different upstream runtime.
- name: Upload runtime under test
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: node-runtime-under-test
path: |
.release/*.tgz
.release/release.json
include-hidden-files: true
if-no-files-found: error
retention-days: 3

node-runtime:
needs: validate
name: Node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: ["22.19.0", "24", "26"]
include:
- os: macos-latest
node: "24"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: ${{ matrix.node }}
package-manager-cache: false

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.1

- name: Install test dependencies
run: bun install --frozen-lockfile

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: node-runtime-under-test
path: .release

- name: Restore validated runtime and launcher
shell: bash
run: |
RUNTIME_TARBALL=$(node -p "JSON.parse(require('fs').readFileSync('.release/release.json', 'utf8')).tarball")
tar -xzf "$RUNTIME_TARBALL" --strip-components=1
node --version
bun --version

- name: Verify Node runtime and packaged installation
run: |
bun scripts/check-runtime.ts
bun scripts/smoke-package.ts

- name: Run runtime integration tests
run: bun run test:runtime

- name: Run real Node SQLite concurrency tests
run: bun run test:node
2 changes: 1 addition & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.12
bun-version: 1.4.1

- name: Install extraction tools
run: sudo apt-get update && sudo apt-get install -y p7zip-full
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.12
bun-version: 1.4.1

- name: Install extraction tools
run: sudo apt-get update && sudo apt-get install -y p7zip-full
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions docs/SQLITE_CONCURRENCY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# SQLite contention regression (#162 / #163)

The CLI's shared session database uses SQLite WAL. Readers can overlap a writer,
but different CLI processes still serialize writes to this file. After successful
store initialization, each connection now waits up to 10 seconds for a write lock.
Both the synchronous constructor and asynchronous `openStartup()` path apply this
setting **after** migrations finish. Startup migration lock budgets, short busy
waits, backoff, rollback, and failure cleanup are unchanged.

This is a bounded contention mitigation, not unlimited concurrency or a retry of
an entire agent turn. A lock held beyond the budget can still fail. The change
does not migrate historical data or alter the database schema. Do not delete the
database or its WAL/SHM files to work around a lock while processes are using it.

## Reproduce and verify

Use Bun 1.4.1 for building and Node >=22.19.0 for the CLI and storage tests:

```sh
bun install --frozen-lockfile
bun run sync:locked
bun run test:node
```

The Node tests use the actual vendored `SqliteSessionStore`, temporary databases,
and independent Node processes. They do not use account credentials or send model
requests. They cover:

- the effective `PRAGMA busy_timeout` after sync open, async startup, and reopen;
- a native session write blocked by another process for 6.5 seconds, longer than
the previous 5-second timeout, then succeeding exactly once;
- a deliberately shortened timeout that reports `SQLITE_BUSY`, writes no session,
and permits a later write after the lock is released;
- four independent processes migrating the same fresh database and persisting
distinct sessions without duplicate migrations or integrity errors;
- killing a writer with uncommitted changes, then reopening without those changes
and successfully writing another session.

The hold-and-release test needs a separate process: `DatabaseSync` blocks the
calling event loop, so a timer in that same process cannot release the lock.
Each test creates its own database and holder rather than reusing the remainder
of a lock window from a previous measurement.

CI builds and packs once, then tests that exact artifact on Node 22.19.0, 24, and
26 on Linux, plus Node 24 on macOS. Bun's `node:sqlite` compatibility implementation
is not used as a substitute for Node's SQLite driver.

## Follow-up boundary

If real workloads still exceed the wait budget, investigate long transactions and
add bounded retries only at persistence boundaries that can be safely rolled back
and replayed. Never retry a whole agent turn and repeat completed external tools.
Per-session databases or a shared writer service are separate architectural changes
requiring discovery, lifecycle, and migration design. Updating to Desktop 3.14.0
also remains separate from this fix's pinned-runtime validation.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@
"check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-session-title.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts && bun scripts/smoke-tui-fullscreen.ts && bun scripts/smoke-tui-fullscreen-switch.ts && bun scripts/smoke-tui-fullscreen-layout.ts",
"check:tui-scenarios": "bun run test:tui",
"test": "bun run test:unit",
"test:all": "bun run test:unit && bun run test:tui && bun run test:runtime",
"test:all": "bun run test:unit && bun run test:tui && bun run test:runtime && bun run test:node",
"test:unit": "bun test test/*.test.ts",
"test:fast": "bun run test:unit",
"test:runtime": "bun test test/runtime/*.test.ts",
"test:node": "node --test test/node/*.test.cjs",
"test:tui": "bun run test:tui:component && bun run test:tui:e2e",
"test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts",
"test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts",
Expand All @@ -79,7 +80,7 @@
"engines": {
"node": ">=22.19.0"
},
"packageManager": "bun@1.3.12",
"packageManager": "bun@1.4.1",
"publishConfig": {
"access": "public",
"provenance": true
Expand All @@ -91,7 +92,7 @@
"devDependencies": {
"@xterm/headless": "6.0.0",
"beautiful-mermaid": "^1.1.3",
"bun-types": "^1.3.14",
"bun-types": "1.4.1",
"cli-highlight": "^2.1.11",
"diff": "^9.0.0",
"just-bash": "^3.4.2",
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {
hasRuntimeCliHelpContract,
hasRuntimeHttpNoContentGuard,
hasRuntimeNetworkRetryGuard,
hasRuntimeSqliteBusyTimeout,
hasRuntimeStreamEofFinishGuard,
patchRuntimeGoalFailurePause,
patchRuntimeHttpNoContent,
patchRuntimeLoginModelDefaults,
patchRuntimeNetworkRetryClassification,
patchRuntimeOfficialMcpAvailability,
patchRuntimeSqliteBusyTimeout,
patchRuntimeStreamEofFinishGuard,
parseRuntimePatchReports,
runtimePatchPlan,
Expand Down Expand Up @@ -77,6 +79,8 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource
|| !hasRuntimeHttpNoContentGuard(runtimeSource)
|| patchRuntimeNetworkRetryClassification(runtimeSource) !== runtimeSource
|| !hasRuntimeNetworkRetryGuard(runtimeSource)
|| patchRuntimeSqliteBusyTimeout(runtimeSource) !== runtimeSource
|| !hasRuntimeSqliteBusyTimeout(runtimeSource)
|| patchRuntimeStreamEofFinishGuard(runtimeSource) !== runtimeSource
|| !hasRuntimeStreamEofFinishGuard(runtimeSource)
|| (patchEnabled("cli-help-contract") && !hasRuntimeCliHelpContract(runtimeSource))
Expand Down
44 changes: 44 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,44 @@ export function patchRuntimeHttpNoContent(runtime: string): string {
return changed ? patched : runtime;
}

export const sqliteBusyTimeoutMs = 10_000;

const runtimeSqliteBusyTimeoutPragma = `pragma busy_timeout = ${sqliteBusyTimeoutMs}`;

/** Verify both successful store-open paths, not an unrelated pragma string. */
export function hasRuntimeSqliteBusyTimeout(runtime: string): boolean {
const pragma = escapeRegExpName(runtimeSqliteBusyTimeoutPragma);
const sync = new RegExp(`try\\{[A-Za-z_$][\\w$]*!==[A-Za-z_$][\\w$]*&&\\([A-Za-z_$][\\w$]*\\(this\\.db,this\\.dbPath,[A-Za-z_$][\\w$]*\\),this\\.db\\.exec\\("${pragma}"\\)\\)\\}catch`, "gu");
const startup = new RegExp(`try\\{return await [A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*)\\.db,\\1\\.dbPath,[A-Za-z_$][\\w$]*\\),\\1\\.db\\.exec\\("${pragma}"\\),\\1\\}catch`, "gu");
return countRegExpMatches(runtime, sync) === 1 && countRegExpMatches(runtime, startup) === 1;
}

/**
* Concurrent zcode processes share ~/.zcode/cli/db/db.sqlite. The runtime opens
* it with a 5s timeout. Async startup temporarily uses a short timeout for
* migration retries and resets it in finally. Apply the steady-state timeout
* AFTER either migration path succeeds, preserving startup's lock budget and
* cleanup. This bounds ordinary write contention; it is not a transaction or
* whole-turn retry, and cannot guarantee success under sustained contention.
*/
export function patchRuntimeSqliteBusyTimeout(runtime: string): string {
if (hasRuntimeSqliteBusyTimeout(runtime)) return runtime;
const sync = /try\{([A-Za-z_$][\w$]*)!==([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\(this\.db,this\.dbPath,([A-Za-z_$][\w$]*)\)\}catch/gu;
const startup = /try\{return await ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\.db,\2\.dbPath,([A-Za-z_$][\w$]*)\),\2\}catch/gu;
if (countRegExpMatches(runtime, sync) !== 1 || countRegExpMatches(runtime, startup) !== 1) {
throw new Error("ZCode runtime is incompatible with the SQLite busy-timeout patch (store migration anchors missing or ambiguous).");
}
const patched = runtime.replace(
sync,
(_match, mode: string, deferred: string, migrate: string, timeout: string) => `try{${mode}!==${deferred}&&(${migrate}(this.db,this.dbPath,${timeout}),this.db.exec("${runtimeSqliteBusyTimeoutPragma}"))}catch`
).replace(
startup,
(_match, migrate: string, store: string, options: string) => `try{return await ${migrate}(${store}.db,${store}.dbPath,${options}),${store}.db.exec("${runtimeSqliteBusyTimeoutPragma}"),${store}}catch`
);
if (!hasRuntimeSqliteBusyTimeout(patched)) throw new Error("SQLite busy-timeout patch failed postcondition verification.");
return patched;
}

function escapeRegExpName(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
Expand Down Expand Up @@ -1530,6 +1568,12 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
apply: patchRuntimeStreamEofFinishGuard,
verify: hasRuntimeStreamEofFinishGuard
},
{
id: "sqlite-busy-timeout",
requirement: "required",
apply: patchRuntimeSqliteBusyTimeout,
verify: hasRuntimeSqliteBusyTimeout
},
{
id: "oauth-http-errors",
requirement: "optional",
Expand Down
78 changes: 78 additions & 0 deletions test/fixtures/sqlite-session-store.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Test-only access to the native store. No diagnostic API is added to the bundle.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const Module = require("node:module");
const path = require("node:path");

function loadSessionStore() {
assert.equal(process.versions.bun, undefined, "SQLite runtime tests must execute in real Node.js");
const file = path.resolve(process.env.ZCODE_TEST_RUNTIME || path.join(__dirname, "../../vendor/zcode.cjs"));
let source = fs.readFileSync(file, "utf8");
const store = /([A-Za-z_$][\w$]*)=class(?: [A-Za-z_$][\w$]*)?\{static\{[A-Za-z_$][\w$]*\(this,"SqliteSessionStore"\)/u.exec(source);
assert.ok(store, "Missing native SqliteSessionStore");
const init = [...source.slice(0, store.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1];
const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source);
assert.ok(init && main, "Missing native store test entry");
source = source.replace(main[0], `${main[0]}${init}();module.exports=${store[1]};return;`);
const runtime = new Module(file, module);
runtime.filename = file;
runtime.paths = Module._nodeModulePaths(path.dirname(file));
runtime._compile(source, file);
assert.equal(typeof runtime.exports.openStartup, "function");
return runtime.exports;
}

function sessionInput(directory, id) {
return { id, projectID: "sqlite-contention-test", directory, slug: id, title: id, version: "test" };
}

async function worker(mode, options) {
assert.equal(process.versions.bun, undefined);
if (mode === "hold") {
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(options.dbPath);
db.exec("BEGIN IMMEDIATE");
if (options.changeTitleFor) db.prepare("UPDATE session SET title = ? WHERE id = ?").run("uncommitted title", options.changeTitleFor);
let finished = false;
let releaseTimer;
const finish = (commit) => {
if (finished) return;
finished = true;
clearTimeout(releaseTimer);
clearTimeout(watchdog);
try { db.exec(commit ? "COMMIT" : "ROLLBACK"); }
finally { db.close(); if (process.connected) process.disconnect(); }
};
const watchdog = setTimeout(() => { process.exitCode = 1; finish(false); }, 25_000);
process.on("disconnect", () => finish(false));
process.on("message", message => {
if (message === "release") finish(true);
if (message === "wait" && !releaseTimer) releaseTimer = setTimeout(() => finish(true), options.holdMs);
});
process.send({ type: "ready" });
return;
}
assert.equal(mode, "startup");
await new Promise(resolve => {
process.once("message", resolve);
process.send({ type: "ready" });
});
const Store = loadSessionStore();
const store = await Store.openStartup({ dbPath: options.dbPath });
try {
assert.equal(store.db.prepare("PRAGMA busy_timeout").get().timeout, 10_000);
await store.createSession(sessionInput(path.dirname(options.dbPath), options.id));
} finally {
store.close();
}
process.disconnect();
}

module.exports = { loadSessionStore, sessionInput };
if (require.main === module) {
worker(process.argv[2], JSON.parse(process.argv[3])).catch(error => {
console.error(error);
process.exitCode = 1;
if (process.connected) process.disconnect();
});
}
Loading
Loading