Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
- apps/server
Steps to reproduce
- On Linux with agent scopes on, run several OpenCode threads on the shared
opencode serve. Every tool command they run lands in the server's own scope (t3code-opencode-<id>.scope), so the server's event loop competes for CPU with each tool process as an equal. - Have one thread run a CPU-heavy workload: a parallel test suite with a few hundred processes while the host is busy (load ~150-220 on 22 cores here, CPU PSI ~87 %).
- The server's JavaScript thread gets starved (worse when part of its heap is in zram swap and has to be decompressed on the starved thread) and stops writing to
/api/eventfor about two minutes.
Expected behavior
T3 rides out a stall of a server that is still alive, or at least reconnects once the server answers again. Threads that did nothing wrong keep their turns.
Actual behavior
T3 drops the stream after 45 s of silence (SILENT_STREAM_TIMEOUT), then makes 5 reconnect attempts 2 s apart. Each attempt's reconcile lists permissions and forms for every active session and is cut off by RECONCILE_TIMEOUT (15 s). The server came back during these attempts, but under the same load the reconcile of ~10 active sessions still took longer than 15 s, so T3 gave up and failed every OpenCode thread on the server at once: "Provider event stream ended unexpectedly". The server process stayed up the whole time, and each turn was still running inside OpenCode.
Timeline (UTC, 2026-10-23, from the OpenCode log and a debug log written inside the server process):
- 05:21:12: OpenCode's own log goes quiet. The in-process log shows gaps of 10 s and 22 s, then nothing from 05:21:55 to 05:23:53 (118 s).
- 05:22:17: T3 logs
Lost the OpenCode event stream; reconnecting. - 05:23:53: the server writes again.
- 05:25:27: T3 logs
Could not reconnect to the OpenCode event stream; failing the session.withTimeoutError: Operation timed out after '15s'fromOpenCode2Adapter.openSession. Three root runs and their subagents fail at 05:25:30.
T3's own event loop was fine meanwhile (no stall over 6 s in event-loop-stalls.ndjson). There was no memory throttling either: memory.events high/max/oom were 0 for the agents slice.
Controlled reproduction of the starvation (4-vCPU VM, a 1.2 GB single-threaded stand-in for the server and N busy loops in one scope, the cpu controller off below the slice as in app-t3code-agents.slice). "Heap walk" is a pass over the whole heap, like a full GC; "swapped" means the heap was pushed to zram first:
| Setup | Longest event-loop stall |
|---|---|
| no load, heap resident | 0.45 s |
| 32 busy loops, nice 0 | 2.1 s |
| 32 busy loops, nice 0, heap swapped | 23 s |
| 96 busy loops, nice 0, heap swapped | 67 s (over the 45 s limit) |
| 96 busy loops at nice 10, heap swapped | 8.7 s |
So the layout lets the tools starve the server, and T3's reconnect budget (45 s plus 5 × (15 s + 2 s)) is shorter than the stall it produces.
Possible directions, either of which would have saved these threads:
- Keep the server's CPU apart from its tools. For example, give tool commands a lower weight or a nice value than
opencode serve, or put the server in a leaf of its own with a higherCPUWeightthan its tools' cgroup (this needs OpenCode's cooperation or a delegated scope). - While the server process is alive, keep waiting for it instead of failing every session. For example, back off with a longer total budget, and don't let a reconcile timeout under load count as a failed reconnect.
We worked around the first point locally: the Cursor provider plugin starts every tool call's shell at nice 10.
Impact
Major degradation or frequent failure
Version or commit
0.1.1-nightly.20261020.3411. The same code is on main a68e6e9: SILENT_STREAM_TIMEOUT in packages/provider-opencode/src/server/v2/OpenCode2Client.ts, and RECONNECT_ATTEMPTS/RECONNECT_DELAY/RECONCILE_TIMEOUT and reconnect in packages/provider-opencode/src/server/v2/adapter.ts. Scope layout from apps/server/src/process/agentScope.ts.
Environment
NixOS, kernel 6.18, systemd 261, 22 cores, 62 GB RAM, zram swap (zstd); OpenCode 2.1.0; headless t3 serve as a systemd user service.
Logs or stack traces
[05:22:17.665] WARN: Lost the OpenCode event stream; reconnecting.
[05:25:27.929] WARN: Could not reconnect to the OpenCode event stream; failing the session.
TimeoutError: Operation timed out after '15s'
at OpenCode2Adapter.openSession (...)
at OpenCode2Adapter.borrowAndOpenSession (...)
[05:25:30.546] WARN: orchestration V2 provider event ingestion failed
ProviderAdapterEventStreamError: Failed while streaming opencode provider session provider-session:provider-instance:opencode:shared events.
[cause]: Error: Provider event stream ended unexpectedly.
Workaround
Lower the priority of the tool processes (we nice them at 10 from the provider plugin), or keep heavy test suites out of the agents' scope. Then send "continue" in each failed thread.
Investigated with Claude Opus 5.5 in T3 Code (OpenCode harness).
Activity
nkoynov commentedon Oct 23, 2026
A second case, a different cause, the same failure (2026-10-23 11:41–11:43 UTC, same versions). The tool processes now run at nice 10, and the OpenCode process wasn't starved this time: a plugin in it kept logging every few hundred ms. But OpenCode serialized all its database work behind one long history load of a 58 MB session (anomalyco/opencode#58437), so
/api/eventwent quiet for 77 s:Lost the OpenCode event stream; reconnecting.RECONCILE_TIMEOUT(15 s) ran out on every attempt.The server was alive the whole time, so a reconnect that keeps waiting for a live server (or a reconcile timeout that scales with the number of sessions) would have saved both incidents.
nkoynov commentedon Oct 23, 2026
What we run locally until T3 handles this, in case the approach helps: a
NODE_OPTIONS=--requirepreload that wraps the server'sglobalThis.fetchfor the OpenCode 2 event subscription only (GET /api/eventwith theopencodeBasic user). No T3 code is patched; constants are as in 3411/3553/main (SILENT_STREAM_TIMEOUT45 s,RECONNECT_ATTEMPTS5,RECONNECT_DELAY2 s,RECONCILE_TIMEOUT15 s, none configurable).What we found while testing it (VM clone on 3553 + OpenCode 2.1.3, plus a small harness running
readEventsand the adapter's reconnect loop on Effect 4.0.4):GETsimply waits, and the first attempt gets through on SIGCONT. Sessions fail when the server comes back slow: each attempt's reconcile (session list, backfill, permissions and forms) runs into the 15 s timeout, five attempts 2 s apart are spent in about 90 s, and every session fails while the server is alive and recovering. That matches both cases above, where reconcile requests took 3 to 8.5 s each after the stall. In the harness, 60 s stopped plus 100 s of 20 s answers fails every session. On the VM, 3 running sessions whose histories hold ~56 MB of inline images each and a 50 s freeze failed all 3 turns: the stream was lost, all 5 reconciles timed out on the backfill, and the sessions failed 100 s after the loss./api/session/activeanswers within 2 s, up to 10 minutes from the stall's start, and backs off between attempts (5, 10, 20, 40, 60 s). A failed attempt is recognisable from outside: the adapter starts reading the stream only afterreconcilesucceeded, so a subscription left unread for 15 s was dropped by its check.Inside T3 the simpler version is probably: while the owned server process is running (
OpenCodeServerOwneralready hasisRunning) and its port accepts connections, keep retrying the reconnect with backoff instead of a fixed 5 × 2 s, and only fail sessions once the process exits or a much longer budget runs out. A spawned server is T3's own child, so "alive" is known without probing.Investigated and written by Claude Opus 5.5 (OpenCode) for @nkoynov.
nkoynov commentedon Nov 6, 2026
#30201 made this worse for a server that stalls while alive.
SUBSCRIPTION_TIMEOUT = "10 seconds"inOpenCode2Client.tsnow applies to every/api/eventsubscription, so the 5 reconnect attempts are used up in about 58 s. Before, a reconnect to a stopped server that still accepted connections just waited and went through when it resumed (stock 3411/3553 reconnected after 70 s and 200 s SIGSTOPs).VM check with two running turns and the OpenCode server SIGSTOPped for 360 s: on nightlies 4205 and 4234 both turns failed 288 s into the stall with "Could not reconnect … timed out after 10s (GET …/api/event)". On 4119 the reconnect was held until the server answered at 362 s, and both turns completed. These runs had the local preload from my comment above, which keeps the stream open and holds the reconnect. Going by the code, without it a stall past about 45 s + 58 s now fails every running turn.
Could the subscription timeout be configurable, or back off and keep retrying while the server's port still accepts connections? Failing fast still makes sense when the port refuses or the process is gone.
nkoynov commentedon Nov 9, 2026
A third way to hit the same
failAll, this time with a healthy server (nightly 4340, OpenCode 2.2.6, 2026-11-08 21:27 UTC):/api/eventresponse normally (200 after 8.8 h). The server wasn't stalled: its in-process logs kept writing every second and the stream was never silent. In 2.2.6,EventFeed(packages/server/src/event-feed.ts) gives each subscriber a 4,096-event dropping queue and fails a subscriber that falls that far behind (SubscriberOverflowError). That's the most likely trigger here, but neither side logs why the stream ended: the adapter'sfollowdrops the cause before "Lost the OpenCode event stream; reconnecting."session.activeand thenbackfillfor every running session, one at a time, pagingmessage.list50 at a time. With ~19 running sessions that took longer thanRECONCILE_TIMEOUT(15 s) on every attempt. Each attempt also started the backfill over from the first session.failAllfailed every running turn and subagent on that server. T3 then dropped the server, so all ~19 OpenCode sessions stopped mid-turn.The constants and
backfillare unchanged on main c4ce7b0. Could the timeout cover each session's backfill instead of the whole reconcile, or could reconcile keep what it has already backfilled across attempts (or read sessions in parallel)? Logging the cause when the stream ends would also help to tell an overflow from a stall.