Repository navigation
[service]: managed service stalls, watchdog recovery loses sessions (404 "session not found"), restart stampede races the service slot (2.0.25 vs 2.0.26) #54181
Description
Description
Three coupled problems around the managed background service (opencode serve --service). Together they make the local server stop responding while work is in progress and silently destroy sessions.
1. The service stalls but stays alive. It keeps writing logs and churning fs watchers, yet stops accepting TCP connections for minutes at a time. Clients report event stream disconnected ... error="Event stream stalled", then the UI hangs. curl http://127.0.0.1:<port>/ times out during a stall and answers 200 in ~2 ms right after. Frequency is growing in my log (~/.local/share/opencode/log/opencode.log):
| date | "Event stream stalled" events |
|---|---|
| 2026-10-04 | 42 |
| 2026-10-07 | 93 |
| 2026-10-08 | 100 |
| 2026-10-09 | 164 |
2. Watchdog "recovery" loses sessions permanently (data loss). When a CLI starts and finds the service unresponsive it kills it:
2026-10-09T11:59:11Z WARN run=89cddd88 message="Background service is unresponsive; recovery cannot preserve persistent terminals" role=cli
2026-10-09T11:59:11Z INFO run=89cddd88 message="background service starting" reason=missing previousVersion=undefined role=cli
Desktop tabs of sessions that were open in the killed instance then 404:
2026-10-09T11:59:17Z INFO run=9f9c5d25 message="Sent HTTP response" http.method=GET http.url=/api/session/ses_edf7c931... http.status=404
2026-10-09T12:00:24Z INFO run=37807470 message="Sent HTTP response" http.method=GET http.url=/api/session/ses_edf7c931.../inbox http.status=404
The Desktop shows "Эта сессия не найдена — эта вкладка указывает на сессию, которой больше нет на этом сервере". The loss is permanent: those sessions are not in opencode.db at all — no rows in session/session_v2, no orphan rows in session_message/message/part, no delete events. Session state that was not flushed to SQLite dies with the killed process. In one evening 3 sessions disappeared this way.
3. Restart stampede races for the service slot. After every stall/recovery all clients spawn their own service; I observed 6-10 opencode serve --service processes starting within 7 seconds, racing for ~/.local/state/opencode/service.json. Losers exit:
2026-10-09T11:59:22Z WARN run=9f9c5d25 message="managed service registration replaced; shutting down" servicePID=51267 observedServicePID=51377 observedVersion=2.0.26 observedURL=http://127.0.0.1:49374 role=server
The stampede interleaves two different CLI builds that share one data dir and one registration file: Desktop's bundled CLI 2.0.25 and the standalone CLI 2.0.26.
4. Client reconnect loop leaks sockets and prolongs stalls. Stuck clients retry without backoff and without a connection cap: 4 stuck opencode processes held 121 / 118 / 101 / 82 sockets each (422 in SYN_SENT at once) against the service port. This saturates the accept queue, so a stall that would otherwise clear in seconds keeps clients locked out:
$ lsof -nP -iTCP:49374 | ...
1 LISTEN, 10-12 ESTABLISHED, 409-422 SYN_SENT
OpenCode version
- OpenCode Desktop 2.0.25 (bundled CLI
~/Library/Application Support/ai.opencode.desktop/cli/2.0.25/opencode-cli) - standalone CLI 2.0.26 (
~/.opencode/bin/opencode) - data dir
~/.local/share/opencode(opencode.db, SQLite, 112 MB), registration~/.local/state/opencode/service.json
Plugins
claude-parity.ts, file-manager, mem0, tool-search (file-manager fails on setup: plugin operation failed ... error="Keymap.Provider is missing", probably unrelated)
Steps to reproduce
- Run OpenCode Desktop plus several
opencodeCLI sessions against the same data dir, with the two builds above (bundled 2.0.25 / standalone 2.0.26) and several MCP servers configured (7 per workspace in my case, ~450 tools total). - Work for a few hours: the service stalls (
Event stream stalledin clients, HTTP on the service port times out). - Start one more
opencodein a terminal: watchdog recovery kills the service ("recovery cannot preserve persistent terminals"). - Open Desktop tabs of the killed sessions -> "session not found"; the session rows are gone from opencode.db.
- Watch 6-10
opencode serve --serviceprocesses race for service.json ("managed service registration replaced; shutting down").
Expected vs actual
Expected: the service stays responsive, or is restarted without losing sessions; only one serve --service instance exists; clients reconnect with backoff on a single connection.
Actual: periodic stalls; recovery silently destroys session data; registration races between mixed CLI versions; clients leak sockets and amplify each stall.
Suggestions
- Make session creation durable before acknowledging it (or flush state before killing the service in recovery). Right now this is silent data loss; the only warning is "recovery cannot preserve persistent terminals".
- Serialize managed-service startup with a file lock instead of "last registration wins", and let clients attach to an existing service rather than spawning one each.
- Reconnect loop: exponential backoff, one connection per client, close sockets on failure.
- Keep the Desktop-bundled CLI and the standalone CLI on the same version/registration semantics.
Additional context
- OS: macOS 26.3.1 (25D771280a), Apple Silicon
- Full log available:
~/.local/share/opencode/log/opencode.log(34 MB, run ids preserved), happy to extract any window on request.
Additional finding (2026-10-09, measured): one slow tool call freezes the whole service
Probing the service with a trivial GET / every 2 s while a session was running tool calls shows hard event-loop blocking, not slowness — either 2 ms responses or complete silence for 20–226 s (no HTTP answers, no log lines at all), then a burst of flushed work:
20:08:21 200 0.002s 20:08:23 200 0.002s 20:08:25 200 0.002s
20:08:27 timeout 20:08:32 timeout ... (7 consecutive timeouts)
20:09:03 200 0.002s <- wakes up, instantly serves everything
17:09:08.861 spawning process: ssh -o ConnectTimeout=15 <my-host> 'bash -s' ...
17:09:08.957-17:09:14 watcher stopped/subscribe/started burst (100+ lines)
17:09:14 -> 17:12:28 log silence 194 s, HTTP unresponsive the whole time
While blocked the process is completely unresponsive (a trivial static GET / does not need DB or MCP), which makes this an event-loop/global-lock blocking issue. Clients then pile up reconnect sockets (422 in SYN_SENT from 4 processes, 82-121 sockets each) and Desktop shows the server as down.
Environment that reliably triggers slow tool calls (details in the local report): two VPN stacks at once (AmneziaVPN + NetBird mesh) with the VPN overlay DNS (100.100.255.254) as the primary system resolver; MCP timewebCloud SSE calls that hang 60-120 s (http.span=121882, Gateway Time-out (HTTP 504)), getaddrinfo ENOTFOUND timeweb.cloud. Also upgrade.opencode.ai does not resolve at all, so the update check fails every 10 minutes.
Suggestion: tool execution and MCP/network I/O must not be able to block request handling for other clients; and the watchdog "recovery" should not silently drop unflushed session state.
Activity
opencode-agent commentedon Oct 9, 2026
Thanks for the detailed report and the measurements. I tried to reproduce this with 2.0.26 on Linux, and none of the problems showed up:
session_v2inopencode.db, so new sessions are written to disk before the server responds.ssh -o ConnectTimeout=15) did not slow the server down. PollingGET /every second during the call, every response took a few milliseconds.opencode serve --serviceprocesses at once left exactly one running, andservice.jsonpointed at it.So the trigger is probably something in your setup I couldn't copy: macOS, Desktop 2.0.25 together with the 2.0.26 CLI, your MCP servers (~450 tools), or the VPN DNS. These would help narrow it down:
17:09:14. Is it MCP tool listing, a config or plugin reload, or the file watcher?timewebCloudMCP server disabled, or with all MCP servers disabled?OPENCODE_DBor any otherOPENCODE_*variables set for Desktop or for your shell? Does theses_edf7c931...ID appear anywhere in your log before the recovery (e.g. aPOST /api/sessionresponse), and in which run? A session that is completely missing from the DB, with no messages, could mean it was created against a different database file.sample <pid> 10 -file stall.txtof theserve --serviceprocess during a stall would show exactly what is blocking it.Related issues, which may cover parts of this: #52049 (the stall watchdog replaces the service and aborts sessions; fix proposed in #52733), #51216 (service SIGKILLed when
/api/infois blocked), #37794 (service blocked for 53 s), #37795 and #48588 (unresponsive service and watchdog kill loops).vkobazev commentedon Oct 10, 2026
Thanks for the quick and thorough triage. Answers below, numbered as in your reply. All timestamps are UTC (the machine is UTC+3).
1. What precedes a long silence (the 194 s gap, 2026-10-09 17:09:14 -> 17:12:28)
Right before it: a tool call spawn AND a file-watcher resubscription burst. The whole minute 17:09 has 76 watcher lines (3 bursts); the silence itself has ZERO log lines of any kind (no MCP listing, no config/plugin reload, no watcher), then watcher lines resume at 17:12:
Watcher lines per minute around the gap: 17:08 -> 0, 17:09 -> 76, 17:10 -> 0, 17:11 -> 0, 17:12 -> 38. So watcher churn appears in bursts around tool executions, not during the silence. The tool call here is one of several (
ssh -o ConnectTimeout=15, 4 of them over ~6 minutes), each followed by a 20-194 s silence. The service watches/,/Users, the home dir and the project dirs withtype=entrieswatchers plus fs-events on the skills dirs.2. Does it still stall with timewebCloud disabled? - Yes, measured today
timewebCloud has been disabled in the global config since 2026-10-09 17:45 UTC, and our two custom plugins were fixed in between (see the side finding below). Polling
GET /every 2 s just now (2026-10-10 13:41-13:42 UTC, one session actively running local tool calls):Earlier today with the same setup: 4/15 timeouts. So it reproduces without timewebCloud. A full "all MCP disabled" run needs a quiet window (a session is active right now); happy to do that A/B on request.
3. Version mix - reproduces on 2.0.26 only as well
The current managed service is 2.0.26 (
service.json: version 2.0.26; Desktop self-updated to 2.0.26 overnight, standalone CLI is also 2.0.26). Stalls continue in this all-2.0.26 state with the same signature. Yesterday the same stalls happened under the 2.0.25-service + 2.0.26-clients mix. The mix amplified restarts (the stampede in the report) but the stall itself is version-independent here. A CLI-only (no Desktop) test is possible on request.4. Lost sessions: DB and env
OPENCODE_*variables in the shell or in any rc file. No second opencode database exists on disk (checked Application Support/ai.opencode.desktop, ~/.opencode, ~/.local/share/opencode, ~/.config/opencode - the only opencode DB is~/.local/share/opencode/opencode.db).ses_edf7c931...appears in the log ONLY as 20 x 404 responses between 11:59:17.066 and 12:00:24.432 (runs9f9c5d25and37807470, i.e. the first seconds after the watchdog recovery at 11:59:11). There is no creation record - but note the log contains no session-creation requests at all: the only logged POSTs in the whole 6-day file are/api/rpc/experimental.browser/attach(53),/api/location/reload(1) and one/experimental/session/.../wait. Creates are simply not visible in the log.ses_edf77b197ffe...at 14:59:26 andses_edf97c0adffe...at 14:24:25 local), which ARE insession_v2of the same DB. They look like same-DB sessions that vanished, not cross-DB ones.5. sample during a stall - already captured
I have two captures of the serve --service process during stalls (auto-triggered: sampling starts the moment
GET /times out). Main thread call graph, 6 s capture during a freeze (4579 samples):The main thread is idle in kevent64 (91%) during the stall - exactly as in a healthy 12 s sample (96% kevent64, same frames). A 45 s capture covering a 16 s stall shows the same distribution (99.4% in the same frame as healthy). No blocking syscall, no getaddrinfo, no sqlite frame: the event loop simply doesn't serve HTTP while the window is frozen. All 28 threads parked as usual in psynch_cvwait/kevent/read. That's why I suspect the HTTP/accept side (or request-queue serialization) rather than a blocked worker. Happy to re-sample with other flags if useful.
Side finding on our side (fixed, but not the root cause)
Our custom
tool-searchplugin did a synchronous read/parse/stringify/write of a 3.2 MB state file from thecontexthook (every model request) and theexecute.afterhook (every tool call) - about 25-30 ms of event-loop blocking per call. It also never removed stale catalog entries (226 tools of a disabled MCP server stayed "searchable"), which made the model call non-existent tools. We patched it (debounced + atomic writes, catalog pruning) and the stalls persist, so it was an amplifier rather than the cause. Possibly worth documenting that plugin hooks run synchronously on the hot path.Related issues
#52049 (watchdog replaces the service and aborts sessions) matches our session loss exactly - the log line before the loss is literally
Background service is unresponsive; recovery cannot preserve persistent terminals. Data loss is the most painful part for us, so #52733 landing would already be a big win.What we can run next (pick any)
opencode-agent commentedon Oct 10, 2026
Thanks for the detailed follow-up. The
samplecaptures help a lot.I tried again with 2.0.26 on Linux, this time copying the pattern you described before each silence. The project had skills in
.claude/skills,.agents/skillsand.opencode/skills. A session made four real shell tool calls over about a minute: slow ones (a 15 s connect to an address that never answers, like yourssh -o ConnectTimeout=15), plus calls that created and deleted 20 skill folders. You can see the skill watcher reloading in the TUI. PollingGET /every second for 150 s, all 148 requests answered within a few milliseconds, with no timeouts.Your samples show the main thread idle in
kevent64while HTTP stops answering, so nothing is keeping the CPU busy. On macOS the service also uses the fs-events watcher backend, including watchers on/,/Usersand your home folder. On Linux it uses a different backend (inotify), so the most likely trigger can't be recreated here. This needs a maintainer with a Mac. Of the runs you offered, these would help most:opencode servewith no custom config or plugins and a blocking shell tool call, while pollingGET /. It would show whether the stall happens without your setup.The lost sessions come from the watchdog killing an unresponsive service, which is the problem tracked in #52049 (proposed fix in #52733). I'm keeping that part in the summary for maintainers.
vkobazev commentedon Oct 10, 2026
Follow-up with a full A/B matrix on this machine (macOS 26.3.1, arm64, 2.0.26 standalone binary). Same protocol everywhere: background poll of
GET /every 0.5 s (1 s timeout) while one agent runs a blocking shell tool call (sleep 45) viaopencode run --server <url> --auto.Result: the stall needs the FULL real environment; every isolated piece is clean
$HOME)So it is NOT the plugins, NOT the MCP servers, NOT the config schema, NOT the DB size/contents, NOT concurrency/locations, and NOT the --service mode per se. It reproduces only when the process runs with the real config dir + real data dir + real state dir together. Also note test 6 proves a 116 MB DB with 258 sessions is fine on a fresh process.
Additional facts from the live instance
psshows CPU 0.2-0.5% and stateSat the exact momentGET /times out. Asamplecapture taken during an idle stall (8 s, 6103 main-thread samples) shows the main thread not in any blocking syscall - no getaddrinfo, no sqlite, no file I/O - and only 3kevent64leaf samples for the whole window (compare a healthy capture: 96% of main-thread samples are inkevent64waiting). So the loop is neither computing nor parked in the poll; it looks like it processes a trickle of internal work and only rarely reaches accept.type=entrieson/,/Users,$HOMEand each project dir.SYN_SENT) because reconnects have no backoff or connection cap. This makes each stall look longer than it is.Remaining deltas between test 8 and the real instance
Everything above isolates the trigger to the real directories as a whole. Not yet isolated separately: the real state dir (
~/.local/state/opencode: prompt-history.jsonl 5.2 MB, locks, pty) and the non-DB contents of the real data dir (shell/,snapshot/,storage/,tool-output/,opencode-perf.log, and the 35 MBopencode.logthat is being appended to constantly). Happy to bisect these two next if it helps - or run whatever instrumented build you want on this machine.Given #52049/#52733 cover the data-loss half (watchdog kills the service and aborts sessions), this report is mainly about the stall half: it is reproducible here 36-37% of the time on the real environment and 0% in every isolated configuration.
vkobazev commentedon Oct 10, 2026
Root cause found - and it is not in the config, plugins, MCP, DB or app logic: the stall is bound to the accumulated kernel-level TCP state of one specific port (127.0.0.1:49374). The opencode process is completely healthy during a stall; the OS simply stops completing TCP handshakes to that destination.
Captured during a sustained stall (4+ consecutive probe failures)
So the SYN never completes and never lands in the accept queue - the process cannot be blamed: its main thread is 99.2% in
kevent64during the stall (6103-sample capture), i.e. idle and waiting for events, CPU 0%.The decisive A/B: same machine, same real environment, different port
--port 49474)During a stall on 49374 a control listener (python http.server) on another port answered in 6 ms - so the OS TCP stack is fine; the failure is specific to that one destination port.
Why restarts never fixed it for the user
The port is sticky: even after deleting
service.json, the nextopencode serve --servicebound 49374 again (the port seems to be derived deterministically in the binary - the string is right next to thecli.service-configcode). Every restart therefore re-entered the same degraded per-port state. Starting with an explicit--port 49474fixed it instantly.What degrades that port
At capture time: 8000+ sockets in TIME_WAIT associated with 49374 (21942 TIME_WAIT machine-wide), and earlier in the day a single stuck CLI client left 82-121 half-open sockets each (422 in SYN_SENT at once) because reconnects have no backoff or connection cap. That is a lot of per-destination TCP history for one 127.0.0.1:port tuple.
Environment note (possible co-factor): macOS 26.3.1 with three overlay/VPN systems active - a Tailscale network extension (
io.tailscale.ipn.macsys.network-extension), NetBird and AmneziaVPN. A network extension can hold per-flow state; the control-port test shows the stack otherwise works.Recommendations
Current state on this machine: service runs on 127.0.0.1:49474, 104 consecutive probes without a single timeout (max latency 5 ms), where the old port failed ~36% of probes minutes earlier.
vkobazev commentedon Oct 10, 2026
Final piece of the puzzle - and it is not an opencode bug at all: the port itself becomes poisoned at the kernel level by TIME_WAIT 4-tuple collisions on loopback.
The mechanism
Connection: Keep-Alive: timeout=5- so the server closes every idle client connection after 5 s. The closing side lands in TIME_WAIT. Out of 8006 TIME_WAIT sockets associated with 127.0.0.1:49374, 7556 are server-side (src=49374).SYN_SENTwith an empty accept queue and a perfectly healthy server process (which matches every capture in my previous comments).Decisive control experiment (today, 18:10 local)
The opencode service was moved to a fresh port (49474) 14 minutes earlier. Then I bound a plain python http.server to the old port and polled both with identical probes:
A different program on the old port fails exactly the same way -> the port is poisoned in the kernel, independent of opencode.
Also interesting: the 8013 TIME_WAIT sockets on 49374 did not expire after 14 minutes without traffic (MSL here is 15 s, yet they persist - macOS keeps them far longer in practice). That is why the user's problem lasted for days and why every restart (same sticky port) kept failing.
Why Linux did not reproduce
Linux accepts a SYN to a TIME_WAIT tuple when it advances the sequence (RFC 6191, and
tcp_tw_reuse), and the test had low connection churn. On macOS loopback with a fixed destination port and heavy keep-alive churn the collisions are frequent.Recommendations (revised with this mechanism)
Environment: macOS 26.3.1 (arm64), MSL=15 s, somaxconn=128, three overlay/VPN systems installed (Tailscale network extension active, NetBird, AmneziaVPN) - probably not required for the mechanism, but they add connection churn of their own.
LZmx commentedon Oct 26, 2026
Same stall + stampede on 2.1.4 (macOS), with a distinct self-shutdown path
Adding a 2.1.4 data point because the recovery loop here ends in a different
message than the one in the report: the running service does not see its
registration replaced, it sees the file gone (
ENOENT) and terminatesitself. No OS kill and no crash report were involved.
Environment
latest, standalone~/.opencode/bin/opencode~/.local/share/opencode,opencode.db4.74 GiB (1.24 M pages,652 k free pages), 1,088 sessions / 65,406 messages / 280,839 parts
What the user sees
The TUI stays on
Restarting service… / Your session will resume automatically.for ~40 minutes. All open sessions show it at once. Nothing reconnects and
nothing new is persisted. Only a machine reboot cleared it.
For reference, that overlay is just a connection indicator in the TUI: it is
shown once
connection.status() !== "connected"for 1 s (reconnecting) or5 s (any other state). It never initiates a restart itself, so it stays up for
as long as the client cannot reach a service.
Timeline (UTC)
No
DiagnosticReportsentry foropencode/bun, no jetsam kill, andPRAGMA integrity_checkon the DB returnsok— so the original process wasnot killed by the OS and the DB was not corrupt. During the loop each spawned
service ran for roughly 80–140 s before reporting
EADDRINUSE, so everydoomed attempt also paid the full startup cost (config, agents, skills, ~430
file watchers, MCP, DB open).
Keep-Alive: timeout=5is still present on responses from 2.1.4 (measuredafter the reboot), so the server-side TIME_WAIT mechanism described at the end
of this thread is still in play. I could not capture TIME_WAIT counts during
the incident; immediately after the reboot the port had 0.
Why recovery cannot converge
serve --service.replacements fail with
EADDRINUSE— after a full startup.~/.local/state/opencode/service.json. Whoever observes the file missingtreats that as "I am no longer the registered service" and exits
(
Interrupt(46)), which hands the port back to nobody.connected, so the overlay never clears.There is no eviction of a stale port holder and no single-writer guarantee for
the registration file.
Suggestions
flock/lockfile in thestate dir). One process starts; the rest wait and attach.
service.jsonas "re-register", not as"another service took over, shut down". A transient ENOENT during a restart
should not kill a healthy listener.
2-minute boot does not end in
EADDRINUSE. If the registered PID no longeranswers
/api/info, evict it instead of refusing to bind.cannot stampede N services.
SO_REUSEADDR/reuseport)to keep the fixed port from accumulating server-side TIME_WAIT tuples.
Related: #54267 (no eviction of a previous listener), #58389 (commands starting
a second service).
iceteaSA commentedon Oct 26, 2026
Same watchdog kill on 2.1.4 (Linux), caused by load on the main thread
Linux x86-64, standalone 2.1.4, one shared service, about 15 TUI clients, many plugins.
What occurs
event stream disconnected ... error="Event stream stalled".ensureprobe of a client times out 3 times in sequence (2 s each). That client stops the service with SIGTERM, then SIGKILL. Two or three clients then startserve --servicein the same 10 ms.Code (current
dev0749024, same as 2.1.4)packages/client/src/effect/service.ts:75-87: after 3 probe timeouts in sequence, the client stops the service and starts a new one.packages/client/src/service-timing.ts:18:requestTimeout: 2_000.Thus the client kills a process that is slow but alive after approximately 6 s. The probe cannot tell a busy service from a hung service.
One cause of the load, for others who see this
On each refresh, the skill config plugin removes all of its skill directory watchers, subscribes again and examines each skill folder again (
packages/core/src/config/plugin/skill.ts:149-191). We saw 66-140watcher subscribelines each second for~/.claude/skillsand~/.agents/skills. This is the same pattern as thewatcher stopped/subscribe/started x76lines in an earlier comment here. When we removed those two directories, the lines stopped immediately and the main-thread load decreased.Possible repairs