Skip to content

TUI input thread freezes for 5-10 s while Zed editor-context poll waits on SQLite lock #28123

@pgilmore

Description

@pgilmore

Description

Summary

opencode's Zed editor-context fallback (introduced in #24352, refined in #24656) opens Zed's SQLite database on the TUI's main thread and polls it at ~1 Hz. When Zed is running concurrently, Zed holds the WAL lock and opencode's read enters SQLite's busy_handler exponential-backoff loop. Because that loop runs on the same thread that does epoll_wait on stdin, keystrokes are not read for the duration of the backoff (~5 s with default busy_timeout). After the timeout, the subsequent pread64() on the WAL file faults pages in from disk → folio_wait_bit_common → another 1-4 s of D-state. End-to-end, the TUI becomes unresponsive in bursts of 5-10 s, intermittently, whenever Zed happens to be active.

This affects any user who runs opencode in a terminal while Zed is open in the same workspace (a very common setup, since the feature exists specifically to bridge them).

Versions

  • opencode 1.15.4
  • Zed 1.1.8
  • Linux 6.8.0 (Ubuntu 24.04.4) — Zed's DB lives on a bind-mounted host filesystem; same behavior reproduces on a plain ext4 mount
  • bun (embedded in opencode bundle)

Reproduction

  1. Open Zed on a workspace and edit a file (so Zed is regularly writing WAL).
  2. In any terminal, launch opencode in the same or a sibling project.
  3. Type continuously. Within minutes, observe sporadic 5-10 s freezes where keystrokes stop being echoed/processed.

Reproduces 100% on Linux when both are open and Zed is actively writing.

Evidence

strace -p <opencode-main-tid> -tt -T -y excerpt during a freeze:

17:54:49.160653 clock_nanosleep(CLOCK_REALTIME, 0, {tv_nsec=175071000}, NULL) = 0 <0.175283>
17:54:49.337335 clock_nanosleep(CLOCK_REALTIME, 0, {tv_nsec=180336000}, NULL) = 0 <0.180516>
17:54:49.519029 clock_nanosleep(CLOCK_REALTIME, 0, {tv_nsec=185679000}, NULL) = 0 <0.185891>
17:54:49.706627 clock_nanosleep(CLOCK_REALTIME, 0, {tv_nsec=191100000}, NULL) = 0 <0.191308>
17:54:50.030317 pread64(43</home/pgilmore/.local/share/zed/db/0-stable/db.sqlite-wal>,
                       ..., 4120, 2146552) = 4120 <0.000124>

The 175ms → 180 → 185 → 191 sequence is SQLite's busy_handler exponential backoff against a contended WAL lock — happening on opencode's main thread, which is the same thread blocked on epoll_wait(stdin). Over 34 s of trace, that thread made 22,352 syscalls against db.sqlite-wal.

Concurrent per-200ms thread-state sampling caught the main thread (TID 5415) in uninterruptible disk wait for 4.2 s straight while waiting for the page-cache fault:

17:51:46.326 → 17:51:50.537  TID 5415  state=D  wchan=folio_wait_bit_common

Both stalls — the busy-wait sleep loop and the D-state read — happen on the input thread, which is why keystrokes are dropped.

Root cause

The bundled code at packages/opencode/src/cli/cmd/tui/context/editor-zed.ts opens Zed's SQLite db with bun:sqlite and runs queries synchronously from what appears to be the TUI's main fiber. Bundled excerpt:

function RD() {
  return [
    process.env.OPENCODE_ZED_DB,
    path.join(os.homedir(), "Library/Application Support/Zed/db/0-stable/db.sqlite"),
    path.join(os.homedir(), ".local/share/zed/db/0-stable/db.sqlite"),
  ].filter(Boolean).find(p => fs.statSync(p)?.isFile?.());
}

…called on every poll tick. The query path opens the live Zed db (no ?mode=ro or PRAGMA query_only), which means Zed's exclusive WAL lock blocks it.

Workaround

Setting OPENCODE_ZED_DB to a path of a quiet, empty SQLite file (or any file that isn't a real db) sidesteps the bug — the query throws no such table, opencode's try/catch returns {type:"unavailable"}, no contention. The Zed integration is effectively disabled. There's currently no documented config-file or CLI flag to disable this feature; the env var is the only knob.

Suggested fixes (any one would resolve it)

  1. Open the db with mode=ro&immutable=1 (or the bun:sqlite equivalent). Zed-side WAL writes won't contend with an immutable reader. Plus a short busy_timeout (e.g. 50 ms) so even unexpected contention can't stall the input thread.
  2. Move the poll off the main fiber. Run it in a worker; post the result back via a channel. The TUI's input loop must never wait on file I/O.
  3. Skip the SQLite fallback when the primary websocket bridge is connected. PR feat(tui): read Zed editor context from state db #24352 describes the SQLite path as a fallback for when the websocket is unavailable, but the strace shows the poll runs continuously regardless. A "websocket connected → don't poll SQLite" gate would eliminate the issue for the common case (Zed running with the websocket bridge).
  4. Add a documented opt-out. A config key like experimental.zedEditorContext: false would let users disable this without relying on the undocumented OPENCODE_ZED_DB env var.

Happy to test any candidate fix on the reproducer.

Plugins

None

OpenCode version

1.15.4

Steps to reproduce

Prerequisites

  • Linux (kernel ≥ 5.x). Should also reproduce on macOS via the Library/Application Support/Zed/... path, but I've only verified Linux.
  • opencode ≥ 1.15 installed.
  • Zed installed (any recent version; tested with 1.1.8).
  • strace, awk, grep for objective verification.

Steps

  1. Start Zed and start writing.

zed ~/somedir & # open any directory in Zed
In Zed, open a file and begin editing — keep typing/saving so Zed is actively writing to its WAL.

  1. In a separate terminal, launch opencode in any project and start an interaction.

cd ~/some-other-project
opencode
Then type a prompt and press enter (so opencode has an active session that wants editor context).

  1. Confirm opencode has opened Zed's db.

OC_PID=$(pgrep -f "opencode/bin/opencode" | head -1)
ls -l /proc/$OC_PID/fd 2>/dev/null | grep -i 'zed/db.*sqlite'
Expected: at least one fd pointing at ~/.local/share/zed/db/0-stable/db.sqlite (or the macOS path).

  1. Watch the main thread state for D-state (uninterruptible disk wait) and SQLite busy-loop sleeps.

In a third terminal, run this sampler — it samples opencode's main thread state every 200 ms:

OC_PID=$(pgrep -f "opencode/bin/opencode" | head -1)
while kill -0 "$OC_PID" 2>/dev/null; do
ts=$(date +%H:%M:%S.%3N)
state=$(awk '{s=$0; sub(/.*) /,"",s); print substr(s,1,1)}' /proc/$OC_PID/stat 2>/dev/null)
wchan=$(cat /proc/$OC_PID/wchan 2>/dev/null)
printf '%s %s %s\n' "$ts" "$state" "$wchan"
sleep 0.2
done | tee /tmp/oc-sampler.log

  1. In opencode, keep typing. Within a few minutes of normal use you should see runs of consecutive samples where the main thread is in one of:
  • D folio_wait_bit_common — kernel waiting for file pages (the pread64 on the WAL).
  • S hrtimer_nanosleep — SQLite's busy_handler exponential backoff.

The freeze visible to the user (keystrokes stop being processed) lines up exactly with these runs. Single freeze events typically span 5-10 s.

  1. (Optional) Strace the main thread to see the syscalls directly.

sudo strace -p $OC_PID -tt -T -y -s 200 -e trace=pread64,clock_nanosleep,nanosleep
-o /tmp/oc.strace
Run for ~60 s, then:

How many syscalls hit Zed's WAL during the trace?

grep -c 'zed/db/0-stable/db.sqlite-wal' /tmp/oc.strace

The SQLite busy-handler backoff pattern (growing sleeps in the 100-200ms range):

grep clock_nanosleep /tmp/oc.strace | grep -oE 'tv_nsec=[0-9]+' | head -20

Expected: many thousands of reads against db.sqlite-wal over a minute, and a series of clock_nanosleep calls whose tv_nsec grows geometrically (e.g. 175000000 → 180000000 → 185000000 →
191000000 → …) — SQLite's default busy-handler.

Workaround that confirms the diagnosis

Restart opencode with the env var pointed at an empty file:

touch /.opencode-zed-stub.sqlite
OPENCODE_ZED_DB=
/.opencode-zed-stub.sqlite opencode

Re-run the sampler. With this in place: no D folio_wait_bit_common, no hrtimer_nanosleep runs, no perceptible freezes — even with Zed actively writing in the background. This isolates
the cause to the Zed-db poll specifically.

Screenshot and/or share link

No response

Operating System

Ubuntu 24.04 LTS

Terminal

Superset / Tilix

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions