Repository navigation
Description
Description
After the host resumes from sleep, the client replaces a healthy background service: it sends SIGTERM (then SIGKILL) to the running opencode serve --service, clears the PTY handoff, and spawns a replacement. In-flight work is interrupted and persistent terminals are lost even though the old process was alive and answering.
Root cause (both clients, @opencode/client Effect and Promise implementations):
service-probe.tsprobes/api/infowith the globalfetchand a 2 sAbortSignal.timeout, so the probe can sit on a stale keep-alive socket from before the suspend and never reach the server.effect/service.ts:74-88(andpromise/service.ts:56-70) counts three consecutive timeouts as "unresponsive" and runs destructive recovery:PtyHandoff.clear()→terminate()(SIGTERM, then SIGKILL) →recruitNow().- A resume also trips the event-stream watchdog (
solid/connection.ts:91,idleTimeout45 s), which calls the reconnect path (cli/.../default.ts:107) →Service.ensure(), so the probe loop runs exactly when the machine comes back from suspend.
There is no check that the process is still alive (kill(pid, 0)), no hysteresis beyond 3 × 2 s, and no awareness that the probe was in flight across a suspend.
Observed timeline (local time, log at ~/.local/share/opencode/log/opencode.log):
16:50:28MCP SSE transports drop in the server;16:50:32the TUI logsevent stream disconnected→ reconnect →Service.ensure()16:50:32 → 16:51:25nothing is logged: host suspended.systemd-resolvedlogsClock change detected. Flushing caches.at16:51:25on resume- three 2 s
/api/infoprobes time out back-to-back (no/api/inforequest from them ever reaches the server, i.e. they were sent on dead pooled sockets) 16:51:31.8the old server starts its teardown (watcher stopped,failed to terminate MCP session) — it was alive the whole time~16:51:36.8SIGKILL (graceful shutdown does not fit the 5 s wait),16:51:37.5fourserve --servicecontenders spawn,16:51:38the TUI reconnects to the new service
Plugins
opencode-roslyn, wsl-notify, superpowers, plus local btw-context.js and zen-failover
OpenCode version
2.1.0 (latest channel, local=false)
Steps to reproduce
- Run the TUI on a laptop with the managed service (
opencode serve --service) and keep the TUI connected for a while. - Suspend the host (lid close / sleep) for at least a minute, so a client request is in flight or the event stream goes idle.
- Resume and watch
~/.local/share/opencode/log/opencode.log(oropencode service status): the registered PID changes, and the previous server logs a graceful shutdown.
Seen once with this exact signature (2026-10-22 16:51 local, WSL2 on Windows 11); the same service replacement also appears at 2026-10-22 06:07 and several times on 2026-10-21 in the same log.
Screenshot
No response
Operating System
Windows 11 26300.9457 with WSL2, Linux 6.18.33.2-microsoft-standard-WSL2
Terminal
Windows Terminal via WSL2, TERM=xterm-256color (no TERM_PROGRAM)
Additional context
What the user sees: the TUI briefly reconnects, the running turn is gone, persistent terminal sessions are lost, and in some reports (see #58000, #57864) the tab loses its directory/session after sleep.
Related but distinct: #57038 covers the same destructive recovery branch when the service really is hung (unbounded respawn storm). This report is about a false positive: a slow/unreachable probe right after resume kills a healthy service. The two share a fix.
Suggested direction (happy to send a PR):
- Probe
/api/infoover a fresh connection (no keep-alive reuse) and re-probe once before counting a timeout. - Do not count probes whose deadline spans a wall-clock jump; reset the timeout counter and the contender pool on resume.
- Before destructive recovery, check
kill(pid, 0): if the process is alive, keep waiting (15–30 s) instead of 3 × 2 s. - Use
pty: "handoff", notPtyHandoff.clear(), whenever a restart is unavoidable. - Keep the policy in the shared probe/ensure code so the Effect and Promise clients (TUI and Desktop) cannot drift.
Activity
opencode-agent commentedon Oct 22, 2026
Thanks for the detailed timeline and code pointers. I couldn't reproduce the false positive here, because it needs a real Windows host sleep with OpenCode running inside WSL2, which this sandbox can't do.
What I tried on 2.1.0 with a real
opencode serve --service:Service.ensure()(the code that probes the service and replaces it if it seems unresponsive) for 30 s, then resumed both. The client reused the existing service; the PID didn't change and nothing was killed./api/inforeally gets no reply for about 7 s, the client does start its recovery: it sends SIGTERM and replaces the service. That is the "service is really hung" case tracked in service: recovery loop spawns unbounded serve processes when service becomes unresponsive #57038 and desktop(win): background service SIGKILLed mid-turn when snapshot capture blocks /api/info for >6s -> ~60s restart loop, aborted turns #51216.So on Linux loopback I couldn't trigger the stale keep-alive socket theory. Whether it happens likely depends on what WSL2 does on resume (VM pause, clock jump, virtual network re-initialisation), or on the service being briefly busy after resume (your log shows MCP connections dropping just before), so it answers
/api/infotoo slowly. This needs a maintainer with a WSL2 laptop to check.If you can, these would help narrow it down:
/api/inforequest in the server log between resume and the SIGTERM? That would show whether the probes reached the server at all.Related issues:
/api/infois blocked for more than 6 sAlexeyShirshov commentedon Oct 22, 2026
Confirmed on 2.1.2 (latest,
local=false), and it no longer needs a real suspend to reproduce.The shipped bundle still contains the same recovery policy —
requestTimeout: 2000,if (count >= 3) → PtyHandoff.clear() → SIGTERM → SIGKILL → recruitNow(), and the probe is still a plainfetch("/api/info")with no control over connection reuse.Deterministic repro against the installed 2.1.2 binary, in an isolated
XDG_*sandbox on port 49400 (a secondserve --servicewith its own state dir, so the real service is untouched). Instead of suspending the host, the test process is frozen withSIGSTOP: it is alive, accepts TCP, but answers nothing — the same shape as a service that just came back from suspend.Timing matches the live incident exactly:
serve --serviceopencode api get /api/info) exits 0 and returns the new pid, so nothing surfaces to the user — only the running turn and persistent terminals are lostRan it three times: 11.8 s / 11.1 s / 13.6 s, the frozen process was killed every time. The live occurrence (16:51 local, WSL2) had the same 6 s to verdict and the same SIGKILL-then-respawn sequence.
Also still true: the recovery branch calls
PtyHandoff.clear(), so a restart triggered this way can never preserve persistent terminals, even when the old server is merely slow rather than dead.cevr commentedon Nov 12, 2026
Upstream text (not posted yet)
Upstream still has this:
Service.ensureevicts the registered service after three timed-out probes. It came in withPR #40896 ("fix(client): evict unresponsive service", merged 2026-08-06), and
upstream/devwas unchanged on2026-11-12 (
git diff HEAD upstream/dev -- packages/client/src/effect/service.tsshows only our fork'sonWaitlines). There is no newer upstream code to take.
Issue #58111 ("healthy background service is killed after host sleep because probe timeouts count as unresponsive",
open) reports the same root cause, through host sleep. Post this as a comment on #58111, not as a new
issue. Also link it from #54810 (slow startup turns into a spawn/retry storm), which overlaps.
The same eviction also fires without sleep: a loaded host is enough. On a Linux box at load 20–60, a service that is
booting or busy misses three 2 s
/api/infoprobes. Anyopencode api …call then runsService.ensure. ItSIGTERMs the live service, then spawns a replacement, and the replacement is slow too. So the next CLI call kills
that one as well.
From one host's
opencode.logon 2026-11-12, 00:00–19:03 UTC:Background service is unresponsive; recovery cannot preserve persistent terminalswarning that a client logged:
opencode api POST /api/rpc/…calls made by health and status scripts;opencode -s <id>).What I'd expect. A client never stops a service it did not start just because probes are slow. A slow probe
does not prove the service is hung: it can be booting, under load, or behind a stale socket after sleep (#58111). The
client could say that it is waiting (
onWait), keep waiting up to its existing deadline, then fail with an errorthat says the service was left running and names
opencode service restart. A user who wants a hung service gonecan still stop or restart it explicitly; that path is
Service.stop, which this change leaves as it is. A contenderthat this same
ensurecall spawned and that then stops answering can still be evicted as today.What we run in our fork.
packages/client/src/effect/service.tsandpromise/service.ts: when a probe times out and the registered pid isnot a live contender of this call (
contenderPool().owns(pid)), ensure reports it, keeps polling untilpromiseTimeout, and recruits no contender against it.It was left running; if it is stuck, restart it with
opencode service restart."test/service.test.tsandtest/promise-service.test.ts:Fork commits: f0c6c66 (tests), 05cdc54 (fix) on cevr/opencode branch
api-probe-kill. Happy to open a PR.