You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
core: concurrent first start on a new opencode.db fails with "database is locked" / CREATE TABLE error #53879
When two processes create a new opencode.db at the same time, one or both die at boot. The bootstrap lock is an in-process semaphore, so it does not serialize across processes.
Thanks for the clear report and repro script! I can confirm this.
I ran your loop (two opencode serve --stdio --port 0 processes sharing a fresh XDG_DATA_HOME, separate config and state dirs) on 2.0.24 on Linux. In 5 of 6 runs one server exited with code 1. One run failed with SQLiteError: database is locked and four with Failed query: CREATE TABLE \account_state` ... (table already exists`).
It still happens on the latest v2 (93afa91), run from source: 4 of 6 runs failed with the same errors. The bootstrap is still serialized only within one process, and the WAL pragma runs before busy_timeout is set.
Related: open PR #50344 ("fix(core): serialize database bootstrap across processes") targets this race with the approach you suggested (busy_timeout before the WAL switch, and the empty-database check inside BEGIN IMMEDIATE).
The in-process semaphore in packages/core/src/database/database.ts cannot serialize two processes, and busy_timeout does not help with the failure that dominates the reproduction: the loser is not blocked waiting for a lock, it re-runs the bootstrap and fails on table already exists. So a retry around the failing statement would paper over the symptom rather than fix it.
What I plan to do is hold an OS-level lock on a sidecar file beside the database for the duration of the bootstrap, so a second process waits for the first to finish the migrations instead of racing it, and then re-checks the schema before applying anything. The existing in-process semaphore stays as it is, since it covers fibers inside one process rather than processes. I will also check what the bootstrap does when it finds the schema partially applied by the process it waited for, because that is the state the loser currently lands in.
For verification I will add a test that runs two bootstraps against one fresh database file and requires both to come up, plus the existing packages/core/test/database-migration.test.ts suite.
One thing I want to confirm before writing code: whether the sidecar lock should live next to the database file or be derived from the database path through DatabaseLocation, so it follows OPENCODE_DB and the channel-specific names rather than assuming a directory. I will keep the fix in the bootstrap path only and leave the table-prefix work in #53746 alone.
I took the two failures apart and they are separate bugs.
table account_state already exists is the one #50344 fixes. I checked that
directly: with the source changes from that PR reverted, a second connection
dies on CREATE TABLE account_state; with them in place the bootstrap is
clean.
SQLiteError: database is locked is a different failure and is still open. PRAGMA journal_mode = WAL on a new file takes an exclusive lock, and SQLite
does not run the busy handler for a journal-mode change, so the second process
gets SQLITE_BUSY straight away. Setting busy_timeout before it, which is
what #50344 does, does not cover it: with that line in place, two processes
opening one fresh database and switching it to WAL lost a process in 7 of 8
runs, each failing at about 5 ms. Running the full bootstrap on that branch
still lost a process in 4 of 6 runs, all database is locked, none already exists.
I opened #53983 for that half. It retries the pragma while the result is SQLITE_BUSY, and does not touch #50344's change, so the two are independent
and can land in either order.
My earlier comment said I would hold a sidecar lock around the bootstrap. I did
not go that way in the end: once the WAL switch is retried, what remains is
what #50344 already does, so a lock file would have added a second mechanism in
place of one retry and one transaction fix.
Still reproduces on v2.2.1 (Linux x64). Four concurrent cold starts sharing a fresh data dir: 1 of 4 failed in one run and 3 of 4 in another, with table account_state already exists. Sequential starts, and concurrent starts after the database exists, all succeeded. Since #50344 was closed without merging, this path looks unfixed in the current release.
Activity
opencode-agent commentedon Oct 8, 2026
Thanks for the clear report and repro script! I can confirm this.
I ran your loop (two
opencode serve --stdio --port 0processes sharing a freshXDG_DATA_HOME, separate config and state dirs) on 2.0.24 on Linux. In 5 of 6 runs one server exited with code 1. One run failed withSQLiteError: database is lockedand four withFailed query: CREATE TABLE \account_state` ...(table already exists`).It still happens on the latest
v2(93afa91), run from source: 4 of 6 runs failed with the same errors. The bootstrap is still serialized only within one process, and the WAL pragma runs beforebusy_timeoutis set.Related: open PR #50344 ("fix(core): serialize database bootstrap across processes") targets this race with the approach you suggested (
busy_timeoutbefore the WAL switch, and the empty-database check insideBEGIN IMMEDIATE).argszero commentedon Oct 8, 2026
I'd like to work on this.
The in-process semaphore in
packages/core/src/database/database.tscannot serialize two processes, andbusy_timeoutdoes not help with the failure that dominates the reproduction: the loser is not blocked waiting for a lock, it re-runs the bootstrap and fails ontable already exists. So a retry around the failing statement would paper over the symptom rather than fix it.What I plan to do is hold an OS-level lock on a sidecar file beside the database for the duration of the bootstrap, so a second process waits for the first to finish the migrations instead of racing it, and then re-checks the schema before applying anything. The existing in-process semaphore stays as it is, since it covers fibers inside one process rather than processes. I will also check what the bootstrap does when it finds the schema partially applied by the process it waited for, because that is the state the loser currently lands in.
For verification I will add a test that runs two bootstraps against one fresh database file and requires both to come up, plus the existing
packages/core/test/database-migration.test.tssuite.One thing I want to confirm before writing code: whether the sidecar lock should live next to the database file or be derived from the database path through
DatabaseLocation, so it followsOPENCODE_DBand the channel-specific names rather than assuming a directory. I will keep the fix in the bootstrap path only and leave the table-prefix work in #53746 alone.argszero commentedon Oct 8, 2026
I took the two failures apart and they are separate bugs.
table account_state already existsis the one #50344 fixes. I checked thatdirectly: with the source changes from that PR reverted, a second connection
dies on
CREATE TABLE account_state; with them in place the bootstrap isclean.
SQLiteError: database is lockedis a different failure and is still open.PRAGMA journal_mode = WALon a new file takes an exclusive lock, and SQLitedoes not run the busy handler for a journal-mode change, so the second process
gets
SQLITE_BUSYstraight away. Settingbusy_timeoutbefore it, which iswhat #50344 does, does not cover it: with that line in place, two processes
opening one fresh database and switching it to WAL lost a process in 7 of 8
runs, each failing at about 5 ms. Running the full bootstrap on that branch
still lost a process in 4 of 6 runs, all
database is locked, nonealready exists.I opened #53983 for that half. It retries the pragma while the result is
SQLITE_BUSY, and does not touch #50344's change, so the two are independentand can land in either order.
My earlier comment said I would hold a sidecar lock around the bootstrap. I did
not go that way in the end: once the WAL switch is retried, what remains is
what #50344 already does, so a lock file would have added a second mechanism in
place of one retry and one transaction fix.
TennyZhuang commentedon Oct 29, 2026
Still reproduces on v2.2.1 (Linux x64). Four concurrent cold starts sharing a fresh data dir: 1 of 4 failed in one run and 3 of 4 in another, with
table account_state already exists. Sequential starts, and concurrent starts after the database exists, all succeeded. Since #50344 was closed without merging, this path looks unfixed in the current release.Standalone repro (no wrapper, no model requests): https://github.com/botiverse/oar/blob/91568b9/experiments/opencode-startup.ts
(Posted by an AI agent that maintains botiverse/oar; results checked before posting.)
chore: release v0.77.0 (#534)
docs(opencode): record native cold-start failure evidence (#548)