Skip to content

Managed background service startup can take minutes and saturates the disk; the CLI's short health check turns it into a spawn/retry storm #54810

Open

Description

@OhnkytaBlabdey

Managed background service startup can take minutes and saturates the disk; the CLI's short health check turns it into a spawn/retry storm

Summary

On a machine whose only data disk is a spinning HDD, launching opencode makes the whole box
unresponsive (a remote session to the machine drops, other local services stall) while the
terminal sits on Starting background server....

The service process is not memory-bound and not CPU-bound. It is bound on file I/O
against its own SQLite store
: ~1.4 GB of file I/O (reads and writes roughly 1:1) with only
~10 s of CPU time and a stable ~300 MB RSS. On this machine that saturates the disk for minutes.

Meanwhile the CLI's health check gives up after roughly 10 seconds, reports
Background service is unresponsive, and starts another service — so the same multi-minute
disk-hammering startup is repeated, and the extra processes collide on the managed service port.

Environment

  • opencode 2.0.27 (installed as the npm package @opencode/cli; the bundled Bun runtime in
    opencode.exe reports Bun 1.4.2)
  • Windows Server 2025 Standard, build 26100, x64
  • 16 GB RAM, page file 6.4 GB
  • Single data disk: 4 TB SATA HDD (Seagate SkyHawk ST4000VX016). No SSD on the machine.
  • Data directory C:\Users\<user>\.local\share\opencode:
    • opencode.db — 5,239,611,392 bytes; page_size=4096, page_count=1,279,202,
      freelist_count=917,638 ⇒ ~3.76 GB (72 %) of the file is free pages, live data ≈ 1.4 GB
    • opencode.db-wal — 314,096,472 bytes (≈76,237 frames of 4096 B), journal_mode=wal
    • Content: 984 sessions, 38,409 messages, 245,697 parts
  • A third-party npm plugin is configured in opencode.jsonc
  • Windows Defender real-time protection is off on this machine, so AV scanning is ruled out

Log evidence

CLI start that never converges (retry storm, and a managed-service port that is already taken by
the still-initializing child):

timestamp=2026-10-12T09:10:53.445Z level=INFO  run=419b0ea5 message="cli starting" version=2.0.27 args=[] role=cli
timestamp=2026-10-12T09:10:53.505Z level=INFO  run=419b0ea5 message="background service starting" reason=missing previousVersion=undefined role=cli
timestamp=2026-10-12T09:11:04.385Z level=WARN  run=419b0ea5 message="Background service is unresponsive; recovery cannot preserve persistent terminals" role=cli
timestamp=2026-10-12T09:11:15.621Z level=WARN  run=419b0ea5 message="Background service is unresponsive; recovery cannot preserve persistent terminals" role=cli
timestamp=2026-10-12T09:11:20.242Z level=INFO  run=2fb50173 message="cli starting" version=2.0.27 args="[\"serve\",\"--service\"]" role=cli
timestamp=2026-10-12T09:11:29.285Z level=INFO  run=83781822 message="cli starting" version=2.0.27 args="[\"serve\",\"--service\"]" role=cli
timestamp=2026-10-12T09:11:40.223Z level=ERROR run=2fb50173 message="cli process failed" cause="...Error: Managed service port 49374 on 127.0.0.1 is already in use by another process..."
timestamp=2026-10-12T09:11:48.942Z level=ERROR run=83781822 message="cli process failed" cause="...Error: Managed service port 49374 on 127.0.0.1 is already in use by another process..."

The same pattern repeats earlier in the log, including
cli process failed ... Error: Timed out waiting for the background service to start.

Startup that is allowed to finish (standalone opencode serve, no CLI in the way) — note the
six-minute silent gap between process start and the first database work, and that the first
log line after that gap is the migration, i.e. nothing is logged while the slow phase runs:

timestamp=2026-10-12T11:28:37.552Z level=INFO run=2785f61a message="cli starting" version=2.0.27 args="[\"serve\",\"--hostname\",\"127.0.0.1\",\"--port\",\"49831\"]" role=cli
timestamp=2026-10-12T11:34:34.803Z level=INFO run=2785f61a message="database migration started" migration=20261010180456_project_time_archived role=server
timestamp=2026-10-12T11:34:34.867Z level=INFO run=2785f61a message="database migration completed" migration=20261010180456_project_time_archived durationMs=64 role=server
$ opencode serve --hostname 127.0.0.1 --port 49831
server listening on http://127.0.0.1:49831        # reached ~6 minutes after process start

Measurements

opencode --version finishes in 0.3 s with a ~2.6 MB working set, so the Bun runtime and
binary loading are not the problem.

opencode serve --hostname 127.0.0.1 --port <p>, sampled every 2 s for 152 s
(process started at idle CPU priority, affinity restricted to one core, killed afterwards):

metric value
elapsed 152 s
process file I/O 1.41 GB (reads and writes roughly 1:1)
CPU time 10.6 s
working set 270–320 MB, stable
\PhysicalDisk(_Total)\% Idle Time 0–2 % (disk saturated)
disk queue length peaks at 5–7
disk avg transfer latency peaks at 352 ms, also 73 ms / 57 ms spikes
files written during the whole run only opencode.db and opencode.db-wal

The only file I/O targets observed were the SQLite database and its WAL; the plugin cache,
config dir, state dir and temp dirs were untouched (verified by scanning for files modified
during the run).

Two runs of the same command:

run time from cli starting to server listening
cold file cache 5 min 59 s
11 minutes later (warm cache) ~100 s

Reproduction

  1. Windows with a spinning HDD as the data disk (this is the environment that freezes; the same
    startup on an SSD is presumably just slow).
  2. Use a store that looks like a long-lived one: a multi-GB opencode.db with a high
    freelist_count ratio, plus a large opencode.db-wal (~300 MB here).
  3. opencode --version → completes in ~0.3 s.
  4. opencode serve --hostname 127.0.0.1 --port <free port> → observe that
    server listening on ... is printed minutes later; sample the process I/O counters
    (Win32_Process.ReadTransferCount / WriteTransferCount) and
    \PhysicalDisk(_Total)\% Idle Time while it runs.
  5. Run opencode (default TUI) in any directory with the machine otherwise idle and watch
    ~/.local/share/opencode/log/opencode.log: background service starting, then
    Background service is unresponsive about 10 s later, then repeated serve --service
    children failing with Managed service port ... is already in use by another process.

Analysis / hypothesis

  1. The service does a large amount of SQLite work before it can serve /api/info. Reads and
    writes are close to 1:1 (~266 MB read / ~261 MB written in one 71 s window), which is the
    shape of a WAL checkpoint / page copy into the main database rather than a pure scan.
    On a 5.2 GB file that is 72 % free pages, with a 300 MB WAL, the scattered page writes are
    expensive; on an HDD they saturate the device (0 % idle, 350 ms transfer latency spikes) and
    stall every other process on the machine.
  2. The CLI's health check (~10 s) and the real startup time (100 s warm, ~6 min cold) are off by
    one to two orders of magnitude, so the CLI declares the service dead while it is still
    initializing.
  3. Because the CLI then starts another service, and the previous child still holds the managed
    port, the retry storm amplifies the disk load and produces the misleading
    port ... is already in use errors.
  4. If the user kills the frozen CLI (the natural reaction), the checkpoint never completes, so
    the large WAL survives and the next launch repeats the same work. The store's free-page
    ratio then keeps growing, which makes the next startup slower — a feedback loop.

Suggested directions

  • Do not start a new managed service while a previous child is still within its startup window;
    make the health check adapt to the child's progress instead of a fixed ~10 s deadline, and
    report the child's exit/state rather than port already in use.
  • Log the phases that currently run silently: there is a multi-minute window with no log output
    at all between cli starting and database migration started.
  • Avoid heavy store work before the server can answer basic health endpoints (answer
    /api/info early / return 503 fast while initialization continues), so a health check can
    succeed early.
  • Bound the WAL: a killed process should not leave ~300 MB of WAL that must be re-checkpointed on
    every launch (PRAGMA journal_size_limit, plus a checkpoint on clean shutdown).
  • Handle extreme free-page ratios: 72 % of a 5.2 GB file was freed pages here, which multiplies
    the I/O of any checkpoint. A periodic/one-off VACUUM (or incremental autovacuum) when the
    freelist ratio crosses a threshold would keep startup bounded as history grows.
  • Surface this to users: the docs/TUI could warn that the first start after a long history (or on
    an HDD) can take minutes and must not be interrupted.

Workaround

With opencode fully closed, shrink the store offline so the next start has far less to do:

PRAGMA wal_checkpoint(TRUNCATE);
VACUUM;

and do not interrupt the first start after that — it is a one-time cost, whereas killing it
keeps the state that makes every following start slow again. (Not yet executed on this machine;
the offline repair is the planned next step.)


中文版(同一 issue 的中文说明)

概要

在一台只有一块机械硬盘作为数据盘的机器上启动 opencode,终端停在
Starting background server...,整机随之失去响应(对这台机器的远程会话断开、其他本地服务
一起卡住)。

服务进程既不是内存瓶颈、也不是 CPU 瓶颈,而是卡在对自己 SQLite 存储的文件 I/O 上:
约 1.4 GB 文件 I/O(读写大致 1:1),CPU 仅约 10 秒,RSS 稳定在约 300 MB。在这台机器上,
这会把它唯一的磁盘打满数分钟。

与此同时,CLI 的健康检查大约 10 秒就放弃,报 Background service is unresponsive,然后又去
启动一个服务——于是这套耗时数分钟的、把磁盘打满的启动被反复执行,新老进程还会争抢托管
服务端口。

环境

  • opencode 2.0.27(npm 包 @opencode/cli 安装;opencode.exe 内置的 Bun 运行时报告
    Bun 1.4.2)
  • Windows Server 2025 Standard,build 26100,x64
  • 16 GB 内存,页面文件 6.4 GB
  • 数据盘只有一块 4 TB SATA 机械硬盘(Seagate SkyHawk ST4000VX016),机器上没有 SSD
  • 数据目录 C:\Users\<user>\.local\share\opencode:
    • opencode.db — 5,239,611,392 字节;page_size=4096、page_count=1,279,202、
      freelist_count=917,638 ⇒ 约 3.76 GB(72%)是空洞页,真实数据约 1.4 GB
    • opencode.db-wal — 314,096,472 字节(约 76,237 帧 × 4096 B),journal_mode=wal
    • 内容规模:984 个会话、38,409 条 message、245,697 条 part
  • opencode.jsonc 中配置了一个第三方 npm 插件
  • 该机器的 Windows Defender 实时防护是关闭的,因此可以排除杀软扫描

日志证据

见上文英文部分的两个日志片段:一个是 CLI 始终无法收敛的启动(先是 10 秒后判定"服务无响应",
随后多个 serve --service 子进程因托管端口已被占用而失败);另一个是允许它跑完的
opencode serve 启动,可以看到进程启动与第一次数据库操作之间有近 6 分钟的静默空档,
即这段最慢的阶段完全没有日志输出。

测量

同样见上文英文部分的表格:opencode --version 0.3 秒完成、工作集约 2.6 MB(说明运行时不
是问题);opencode serve 实测 152 秒内产生 1.41 GB 文件 I/O、CPU 仅 10.6 秒、内存
稳定 270–320 MB,而磁盘 % Idle Time 只有 0–2%(被打满)、队列峰值 5–7、单次传输延迟
峰值 352 ms;整个过程中被写入的文件只有 opencode.db 和 opencode.db-wal
(插件缓存、config、state、临时目录都没有文件被写)。

同一命令两次运行的差别:冷缓存时从 cli starting 到 server listening 用了
5 分 59 秒;11 分钟后再跑(缓存已热)约 100 秒。

复现

  1. Windows 机器,数据盘为机械硬盘(这是会卡死的环境;SSD 上大概只是慢)。
  2. 准备一个"长期使用过"的存储:数 GB 的 opencode.db 且 freelist_count 占比很高,外加一个
    较大的 opencode.db-wal(本例约 300 MB)。
  3. opencode --version → 约 0.3 秒返回。
  4. opencode serve --hostname 127.0.0.1 --port <空闲端口> → 观察 server listening on ...
    要等数分钟才打印;期间采样进程 I/O 计数
    (Win32_Process.ReadTransferCount / WriteTransferCount)与
    \PhysicalDisk(_Total)\% Idle Time。
  5. 在任意目录运行 opencode(默认 TUI),机器保持空闲,观察
    ~/.local/share/opencode/log/opencode.log:先是 background service starting,约 10 秒后
    Background service is unresponsive,接着多个 serve --service 子进程以
    Managed service port ... is already in use by another process 失败。

分析与假设

  1. 该服务在能够响应 /api/info 之前做了大量 SQLite 工作。读写量接近 1:1(一个 71 秒窗口内
    读约 266 MB、写约 261 MB),这个形态更像是把 WAL 页回拷进主库的 checkpoint,而不是
    纯扫描。在一个 5.2 GB、其中 72% 是空洞页、外加 300 MB WAL 的文件上,这些分散的页写入
    代价很高;在机械盘上就直接打满设备(%idle 0、延迟峰值 350 ms),把机器上其他进程一起拖住。
  2. CLI 的健康检查(约 10 秒)与真实启动耗时(热 100 秒、冷约 6 分钟)差一到两个数量级,
    因此它在服务仍在初始化时就判定其已死。
  3. 之后 CLI 再启动一个服务,而前一个子进程仍占着托管端口,于是重试风暴放大了磁盘负载,并
    产生误导性的 port ... is already in use 报错。
  4. 如果用户在卡死时把 CLI 杀掉(这是很自然的反应),checkpoint 永远无法完成,大体积 WAL
    被保留,下次启动又要重做同样的工作;同时空洞页比例继续增长,使下一次启动更慢——形成
    正反馈。

建议方向

  • 前一个子进程仍在启动窗口内时,不要启动新的托管服务;健康检查应随子进程的进展自适应,
    而不是固定的约 10 秒死线;并且应上报子进程的真实状态,而不是 port already in use。
  • 给目前完全静默的阶段加日志:从 cli starting 到 database migration started 之间存在
    数分钟没有任何输出的空档。
  • 在服务能响应基础健康端点之前,避免做重量级存储工作(例如先快速响应 /api/info 或按
    503 快速失败,把重活放到后台继续),这样健康检查可以更早通过。
  • 限制 WAL 体积:进程被杀之后不应留下约 300 MB 的 WAL 让每次启动重新 checkpoint
    (PRAGMA journal_size_limit,以及干净退出时做一次 checkpoint)。
  • 处理极端的空洞页比例:本例 5.2 GB 的文件里有 72% 是已释放页,会让任何 checkpoint 的 I/O
    成倍放大。当 freelist 比例超过阈值时做一次 VACUUM(或启用增量 autovacuum),可以让
    启动开销随历史增长仍然可控。
  • 让用户可见:文档/TUI 可以提示"历史很长时(或在机械盘上)首次启动可能长达数分钟,请勿中断"。

规避办法

在 opencode 完全关闭的情况下,离线收缩存储,让下一次启动少做很多事:

PRAGMA wal_checkpoint(TRUNCATE);
VACUUM;

之后不要中断第一次启动——那是一次性代价;而杀掉它恰好会保留让每次启动都变慢的状态。
(本机尚未执行,离线修复是下一步计划。)

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and measurements. I could reproduce this on 2.0.27, and it still happens on the latest dev (bcf23b6).

    What I did on Linux, with a fresh data directory:

    1. Left a ~500 MB un-checkpointed WAL with many freed pages next to the database (a sqlite3 writer killed before it could checkpoint).
    2. Made the disk slow by adding a 2 ms delay to every read and write with strace.
    3. Ran opencode service start.

    The log shows Background service is unresponsive about every 11 seconds. Each time the CLI kills the service that is still starting and spawns a new one. The WAL checkpoint never finishes, so the main database file never grows. On dev one of the extra services also failed with Managed service port ... is already in use by another process, the same error you saw. Without the slowdown, the same store starts in under a second.

    The script below reproduces it. I couldn't test the machine-wide freeze, since that needs a real HDD, but the kill-and-respawn loop that repeats the expensive startup work is confirmed.

    Related: #41739 (similar stall on a large database, caused there by the V1 migration), #54267 (port already in use while an older service is still alive), #54181 and #37795 (a busy service treated as dead by short health probes).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #54810: a slow-starting managed service (large WAL to checkpoint on a slow disk)
    # is declared "unresponsive" by the CLI after ~10s, killed and replaced, over and over,
    # so the WAL is never checkpointed.
    # Needs: opencode (or OC_DEV=/path/to/checkout), sqlite3, strace (slow disk is simulated by
    # delaying every pread64/pwrite64 by 2 ms).
    set -u
    ROOT=/tmp/opencode/repro54810
    rm -rf "$ROOT"; export HOME="$ROOT/home"; mkdir -p "$HOME"
    if [ -n "${OC_DEV:-}" ]; then
      cd "$OC_DEV/packages/cli"  # its bunfig.toml preload is needed by the spawned service child too
      OC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
    else
      OC=(opencode)
    fi
    "${OC[@]}" --version
    D="$HOME/.local/share/opencode"
    NAME=opencode; [ -n "${OC_DEV:-}" ] && NAME=opencode-local  # source builds use opencode-local.db/.log
    
    # 1. create the store, then stop the service
    timeout 60 "${OC[@]}" service start
    pkill -9 -f 'serve --service' ; sleep 1
    
    # 2. leave a ~500 MB WAL behind (writer killed before checkpoint), with many freed pages
    { echo "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE junk(x BLOB);
    WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM c WHERE i<250000) INSERT INTO junk SELECT randomblob(1000) FROM c;
    DELETE FROM junk WHERE rowid%4<>0; SELECT 'done';"; sleep 600; } | sqlite3 "$D/$NAME.db" > "$ROOT/sq.out" 2>&1 &
    for i in $(seq 120); do grep -q done "$ROOT/sq.out" 2>/dev/null && break; sleep 1; done
    pkill -9 -x sqlite3; sleep 1
    ls -la "$D" | grep db
    
    # 3. start the service on a simulated slow disk
    timeout -s KILL 90 strace -f -qq -o /dev/null -e trace=pread64,pwrite64 \
      -e inject=pread64:delay_exit=2000 -e inject=pwrite64:delay_exit=2000 \
      "${OC[@]}" service start > "$ROOT/start.out" 2>&1
    grep -v -e dispatch_event -e '^ *at ' "$ROOT/start.out"
    pkill -9 -f 'serve --service'; pkill -9 -x strace
    
    echo "--- log ---"
    grep -E 'service starting|unresponsive|cli process failed|serve\\",\\"--service' "$D/log/$NAME.log" | cut -c1-220
    ls -la "$D" | grep db
    echo "unresponsive-kill cycles: $(grep -c 'Background service is unresponsive' "$D/log/$NAME.log")"

    Run it: bash repro.sh

  2. OhnkytaBlabdey commented on Oct 12, 2026

    @OhnkytaBlabdey
    Author

    Follow-up: offline repair results, a correction, and what actually writes to the store at startup

    1. Correction to the original report

    The original report suggested that a ~300 MB WAL had to be re-checkpointed on every launch. I
    have to withdraw that as a mechanism: on this machine the WAL file was 314,096,472 bytes but
    PRAGMA wal_checkpoint(TRUNCATE) returned 0|0|0, i.e. the file had simply never been
    truncated and there was nothing to say it held pending frames. The measured facts in the
    original report (duration, I/O volume, CPU, RSS, disk saturation) stand; the "300 MB
    re-checkpoint each launch" explanation does not, and the real writer is described in §3 below.

    2. Offline repair (store shrunk, startup no longer minutes)

    Performed with opencode fully closed, on the same machine:

    VACUUM INTO '<backup path>';          -- compacted copy, also serves as a backup
    PRAGMA wal_checkpoint(TRUNCATE);
    VACUUM;
    PRAGMA integrity_check;               -- ok

    Results:

    item before after
    opencode.db 5,239,611,392 B 2,056,183,808 B
    free pages (freelist_count) 917,638 pages 0
    opencode.db-wal 314,096,472 B 0 B
    integrity_check — ok
    sessions / messages / parts 984 / 38,409 / 245,697 984 / 38,409 / 245,697

    Total wall time for the three steps: 5 min 09 s (77 s for VACUUM INTO, 228 s for VACUUM).

    Effect on startup, same command line (opencode serve --hostname 127.0.0.1 --port <p>), same
    machine:

    time from process start to server listening
    before repair, cold file cache ~5 min 59 s
    before repair, warm cache ~100 s
    after repair 10–12 s

    So the multi-minute startup is gone. This is on a machine whose only disk is a 4 TB SATA HDD.

    3. What still writes: startup populates the v2 tables

    The repair does not stop the heavy writes. In short guarded runs after the repair, the
    process still performed 0.7–1.4 GB of file I/O in the first ~1 minute, and the store kept
    growing even though the v1 row counts never changed.

    Measured before → after the first start → after a second start:

    table / file after repair after a 45 s start after a 72 s start
    session 984 984 984
    message 38,409 38,409 38,409
    part 245,697 rows / 1,207,555,189 B unchanged unchanged
    session_v2 0 668 863
    session_message 0 24,637 34,906
    opencode.db size 1.96 GB 2.13 GB 2.45 GB
    freelist_count 0 0 0

    Observations:

    • The writes are new rows in session_v2 / session_message (the v2-shaped tables), populated
      from the existing v1 data (session / message / part), while part content is untouched.
    • The work is progressive: each process start advances it (session_v2 0 → 668 → 863 out of
      984 sessions) and costs ~170–320 MB of store growth and ~0.7–1.4 GB of I/O.
    • Because a start in this environment (HDD, large history) is heavy enough to make the machine
      unresponsive, it has been interrupted every time, so the backfill never finishes and every
      launch repeats a large chunk of work. The store had reached 5.24 GB with 72 % free pages
      this way.
    • The additional per-start I/O (0.7–1.4 GB for what is effectively "continue migrating my own
      history") is also what keeps the machine unusable during the first minute of a start, even
      after the store has been compacted.

    Questions:

    1. Is this v1 → v2 backfill expected to be chunked per process start like this, and is it
      resumable/idempotent? Can it be run to completion offline (a CLI command, or a documented
      "migrate and exit"), instead of depending on the server surviving long enough?
    2. During the backfill there is no log output at all between cli starting and the migration
      lines — could the backfill report progress so users don't have to guess whether it is hung?
    3. Should the backfill be rate-limited or deferred so that a first start after an update does
      not need to rewrite the whole store while the user is waiting for a TUI?

    4. Reproduction

    -- row counts before a start
    SELECT count(*) FROM session_v2, session_message;  -- (two queries: 0 and 0 here)
    -- start the server once and let it run for ~1 minute, then stop it
    SELECT count(*) FROM session_v2;      -- 668
    SELECT count(*) FROM session_message; -- 24637

    The same growth is visible as PRAGMA page_count increasing by tens of thousands of pages per
    start while PRAGMA freelist_count stays 0.

    5. Update: the backfill does finish, and after that the start is quiet

    A third start was allowed to run for 116 s while per-process I/O deltas were sampled every 5 s:

    measurement value
    server listening 5 s
    total process I/O read 300 MB / write 288 MB
    write delta after ~50 s 0 MB per 5 s sample, for the remaining ~70 s
    session_v2 863 → 984 (= number of sessions)
    session_message 34,906 → 39,347
    session / message / part 984 / 38,409 / 245,697 — unchanged
    opencode.db 2.45 GB → 2.58 GB, freelist_count 0

    So the run of three starts after the offline repair moved 0 → 668 → 863 → 984 sessions, and the
    third one finished and then went completely silent on writes.

    Practical consequence: the "first start after this migration" is a one-off heavy operation
    (~1–2 minutes of sustained I/O once the store is compact), and it has to be allowed to finish —
    interrupting it makes the next start repeat a chunk of it. Doing VACUUM +
    wal_checkpoint(TRUNCATE) offline before that first start shortens the waiting a lot (here
    server listening went from ~6 min to 5–12 s), but it does not remove the backfill itself.


    后续补充:离线修复结果、一处更正,以及启动时到底在写什么

    1. 对原报告的一处更正

    原文推测"每次启动都要重新 checkpoint 约 300 MB 的 WAL"。这条作为机制我要撤回:在这台机器
    上 WAL 文件确实是 314,096,472 字节,但 PRAGMA wal_checkpoint(TRUNCATE) 返回 0|0|0,说明它只
    是一个从未被 truncate 的文件,没有任何证据表明里面有待回拷的脏页。原文里实测的部分(耗时、
    I/O 体量、CPU、RSS、磁盘打满)仍然成立;"每次启动重做 300 MB checkpoint" 这个解释不成立,
    真正的写入方见第 3 节。

    2. 离线修复(库已收缩,启动不再是分钟级)

    在 opencode 完全关闭的情况下,于同一台机器上执行:

    VACUUM INTO '<backup path>';          -- 压缩式副本,同时作为备份
    PRAGMA wal_checkpoint(TRUNCATE);
    VACUUM;
    PRAGMA integrity_check;               -- ok

    结果:

    项目 修复前 修复后
    opencode.db 5,239,611,392 B 2,056,183,808 B
    空洞页(freelist_count) 917,638 页 0
    opencode.db-wal 314,096,472 B 0 B
    integrity_check — ok
    会话 / message / part 984 / 38,409 / 245,697 984 / 38,409 / 245,697

    三步合计耗时 5 分 09 秒(VACUUM INTO 77 秒,VACUUM 228 秒)。

    对启动耗时的影响(同一命令行 opencode serve --hostname 127.0.0.1 --port <p>、同一台机器):

    从进程启动到 server listening
    修复前、冷缓存 约 5 分 59 秒
    修复前、热缓存 约 100 秒
    修复后 10–12 秒

    也就是说,分钟级启动已经消失(该机器唯一的数据盘是一块 4 TB SATA 机械硬盘)。

    3. 仍然在写的是什么:启动时会填充 v2 表

    修复并没有消除重负荷写入。修复后几次受限运行中,进程在启动后第一分钟内仍然产生
    0.7–1.4 GB 文件 I/O,并且库在持续增长,而 v1 表的行数没有任何变化。

    实测(修复后 → 第一次启动后 → 第二次启动后):

    表 / 文件 修复后 跑 45 秒后 跑 72 秒后
    session 984 984 984
    message 38,409 38,409 38,409
    part 245,697 行 / 1,207,555,189 B 不变 不变
    session_v2 0 668 863
    session_message 0 24,637 34,906
    opencode.db 体积 1.96 GB 2.13 GB 2.45 GB
    freelist_count 0 0 0

    观察:

    • 写入的是 session_v2 / session_message(v2 形态的表)里的新行,来源是已有的 v1 数据
      (session / message / part),而 part 的内容完全没动。
    • 这是渐进式的:每次启动都会往前推进(session_v2 0 → 668 → 863,共 984 个会话),
      单次代价约 170–320 MB 的库增长和 0.7–1.4 GB 的 I/O。
    • 在这种环境(机械盘 + 大量历史)下,启动本身就重到会让机器失去响应,于是每次都被中断,
      回填因此永远跑不完,下次启动再重复一大段工作。这个库就是这样涨到 5.24 GB、72% 是空洞页的。
    • 这份"每次启动额外 0.7–1.4 GB I/O、只为继续迁移自己的历史"的开销,也是库收缩之后、启动
      第一分钟内机器仍然不可用的原因。

    想请教:

    1. 这个 v1 → v2 回填是否本来就设计成"每个进程启动推进一块"?它是否可恢复/幂等?能否离线
      跑完(比如一个 CLI 命令,或"迁移完就退出"的模式),而不是依赖服务能活到跑完?
    2. 回填期间从 cli starting 到迁移日志之间完全没有输出——能否打印进度,让用户不必猜它是不是
      卡住了?
    3. 是否应该给回填限速或延后执行,使"升级后第一次启动"不必在用户等 TUI 的时候重写整个库?

    4. 复现

    -- 启动前统计行数(本例均为 0)
    SELECT count(*) FROM session_v2;
    SELECT count(*) FROM session_message;
    -- 启动服务并让它跑约 1 分钟,然后停掉
    SELECT count(*) FROM session_v2;      -- 668
    SELECT count(*) FROM session_message; -- 24637

    同样的增长也可以从 PRAGMA page_count 每次启动增加数万页、而 PRAGMA freelist_count 保持 0
    观察出来。

    5. 更新:这个回填确实会跑完,跑完之后启动就安静了

    允许第三次启动连续运行 116 秒,同时每 5 秒采样进程 I/O 增量:

    观测项 数值
    server listening 5 秒
    进程 I/O 总量 读 300 MB / 写 288 MB
    约 50 秒后的写入增量 每个 5 秒采样均为 0,持续其后约 70 秒
    session_v2 863 → 984(等于会话总数)
    session_message 34,906 → 39,347
    session / message / part 984 / 38,409 / 245,697 — 不变
    opencode.db 2.45 GB → 2.58 GB,freelist_count 为 0

    也就是说,离线修复之后的三次启动把它推成了 0 → 668 → 863 → 984 个会话,第三次跑完,随后写入
    完全静默。

    实际含义:这次迁移之后的"第一次启动"是一次性的重活(库收缩后仍需 1–2 分钟持续 I/O),必须让
    它跑完——中断它会让下次启动重复其中一段。在首次启动之前离线做 VACUUM +
    wal_checkpoint(TRUNCATE) 能大幅缩短等待(本例 server listening 从约 6 分钟降到 5–12 秒),
    但并不能省掉回填本身。

  3. Dante-dan commented on Oct 12, 2026

    @Dante-dan
    Contributor

    I’m taking the managed-service startup retry fix. I’m checking the health-probe recovery against the existing slow-start and unresponsive-service contracts so a still-initializing child can finish within the startup window.

  4. rorar commented on Oct 19, 2026

    @rorar

    Another data point for the same spawn storm, on Linux 2.0.32. Here the trigger was memory pressure, not a slow disk. Two details differ from the report above: the extra serve --service processes did not exit, and each new process made the stall worse.

    Environment

    • opencode 2.0.32 (latest channel). The service ran 2.0.32. The 4 TUI clients were still the 2.0.24 binary in memory, because the update replaced the binary under them.
    • Linux, inside an LXC container with cgroup v2 limits: memory.high 28 GiB, memory.max 32 GiB, no swap.
    • 4 long-running TUI clients in 4 projects (oldest 11 days), all sharing the default service on port 49374.

    What happened

    An unrelated process in the container slowly grew to 8 GB and pushed the cgroup above memory.high. The kernel then throttled and reclaimed continuously (memory.events high: 137 million, pgmajfault: 1.46 billion). Nothing was OOM-killed, so the container stayed in this state for hours.

    The service boot time (location services booted durationMs, max per hour, UTC) shows the onset:

    hour max boot time
    2026-10-07 to 2026-10-18T18 0.1 to 1.3 s
    2026-10-18T20 5.8 s
    2026-10-19T01 31 s
    2026-10-19T02 376 s
    2026-10-19T04 onward no boot completed

    At 2026-10-19T01:29Z the running service stalled. FileSystem.isDir and FileSystem.resolve calls ended in InterruptError: All fibers interrupted without error, requests ran for about 128 s and ended with status 499, and the service stopped its watchers. Within 10 seconds the 4 clients started 7 serve --service processes. From then on every client logged Timed out waiting for the background service to start and started another one every 3 to 10 minutes.

    Counts from opencode.log for 2026-10-19 04:00Z to 15:59Z:

    count
    serve --service starts 232 (about 40 per hour)
    failed with Managed service port 49374 on 127.0.0.1 is already in use by another process 198
    answered at least one HTTP request 3
    logged only cli starting, nothing else 31

    The extra processes stayed alive

    When I stopped it, 66 serve --service processes were alive, 16 or 17 per client. The parent PID of each one was its TUI client. They were started over 5 hours. 12 were in state D, most of the others in state R. There were also [opencode] <defunct> zombies under the clients.

    Each process held only 35 to 90 MB resident, but under reclaim every one of them adds pressure and CPU time. So the storm feeds itself: the slower the service boots, the more contenders start, and the more contenders run, the slower everything gets. The host (12 cores) reached a load average of 202 with 91 % system CPU time, so other workloads on the same host stalled too.

    I cannot tell from the log whether the 66 survivors are contenders that hit the port error and then failed to exit, or contenders that hung before they reached the bind. The log has no exit line for either case.

    Without memory pressure the logic works. With a healthy service on 49374, a second serve --service exited at once and the first one kept the port.

    Recovery

    Killing the 4 clients, all serve --service processes and the memory hog ended it. SIGTERM was not enough under reclaim, so SIGKILL was necessary. The run queue dropped from about 160 to 3 to 8 runnable processes within 1 minute.

    Suggestions for the fix in #54883

    • Cap the number of contenders and back off exponentially. A fixed retry every few minutes per client never converges while the machine stays slow.
    • Let only one client start a contender at a time. Here 4 clients started 7 contenders in 10 seconds.
    • Make sure a contender that loses the port exits, and that the client reaps or kills the children it started.

    This comment was drafted together with Claude Code (Claude Opus 5.5). The numbers come from my opencode.log, from ps and from the cgroup files, and were checked on the affected machine before posting.

  5. neriousy commented on Oct 23, 2026

    @neriousy
    Member

    Preserving the additional reproduction from #57038: on Windows, an nthLink network stall triggered the same managed-service recovery loop, with roughly 40 changing PIDs and repeated outbound requests. Please ensure the retry work in #54883 covers this network-stall trigger as well as slow startup.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions