Skip to content

core: concurrent first start on a new opencode.db fails with "database is locked" / CREATE TABLE error #53879

Open

Description

@Starefossen

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.

Version: 2.0.24, macOS arm64

Repro (new $R each run):

for w in a b; do
  ( exec 0< <(sleep 15)
    XDG_DATA_HOME=$R/dt XDG_CONFIG_HOME=$R/$w/cfg XDG_STATE_HOME=$R/$w/st OPENCODE_PASSWORD=x \
      opencode serve --stdio --port 0 >$R/$w.out 2>$R/$w.err ) &
done

In 6 of 8 runs at least one server failed: SQLiteError: database is locked or Failed query: CREATE TABLE ....

Expected: both start. Actual: the loser exits (orDie).

Source: packages/core/src/database/database.ts:49,57-64

Fix: run the bootstrap inside BEGIN IMMEDIATE and re-check for the session table, or use a lock file next to the DB.

Activity

  1. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    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).

  2. argszero commented on Oct 8, 2026

    @argszero
    Contributor

    I'd like to work on this.

    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.

  3. argszero commented on Oct 8, 2026

    @argszero
    Contributor

    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.

  4. TennyZhuang commented on Oct 29, 2026

    @TennyZhuang

    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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions