Skip to content

Commit c64fe07

Browse files
authored
Harness driver waits for its child; adoption stops when nothing is pending; SDK calls time out (#9)
1 parent 81b9ede commit c64fe07

11 files changed

Lines changed: 617 additions & 38 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22

33
## 0.2.5
44

5+
- Every SDK call the server plugin makes, the TUI plugin's entry point (`session.list`), and the
6+
transcript fetcher (`session.get`/`session.messages`) now carry a 15s timeout
7+
(`AbortSignal.timeout`) instead of waiting on the server forever: if the OpenCode server itself
8+
stops answering, a hook or a route now fails after 15s and falls back, rather than hanging with
9+
it. (`src/tui/actions.ts` is left untimed on purpose — most of its calls are `session.prompt`,
10+
which legitimately runs for a whole turn. A stalled *provider* is also a different case — the
11+
server keeps answering, and a queued `/ctree` turn simply waits its turn; see the USAGE note
12+
below.)
13+
14+
- `adoptSoon` (native-fork adoption after `session.created`) now also stops retrying as soon as
15+
a pass finds nothing left to adopt, not only once it adopts something — a native fork batch
16+
fires two `session.created` events, and the sibling loop that lost the race to adopt both
17+
forks used to poll three times a second apart for nothing.
18+
19+
- `harness/pty-run.py` now SIGTERMs its child, drains remaining output, and SIGKILLs/waits for
20+
it before exiting, instead of exiting with the child possibly still alive — a live child left
21+
behind after the driver exits can spin at 100% CPU once its pty master goes away.
22+
23+
- `docs/USAGE.md` notes that `/ctree status` (and other `/ctree` subcommands) queue behind a
24+
running turn, where `/tree` opens synchronously from the local journal, its fork-adoption pass
25+
running off the critical path — useful when a turn looks stuck.
26+
527
- **The model that answered is no longer invisible.** `TranscriptMessage` now carries the
628
assistant's `providerID`/`modelID` (it was already on OpenCode's own message, just never
729
copied over), so the inspector shows a `Model` line for assistant turns and steps, not only
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Brief: did opencode-context-tree (or a hung LiteLLM connection) cause the 2026-09-04 hangs?
2+
3+
Handoff from the roost side. Read-only on roost; you own the investigation
4+
on the plugin side. Goal: reproduce or rule out each hypothesis below with
5+
numbers, not a fix. Fixes come after the diagnosis.
6+
7+
## What happened
8+
9+
On 2026-09-04, while a driven OpenCode session with this plugin loaded was
10+
running inside roost (terminal multiplexer, `~/repos/roost`), roost wedged
11+
twice: sustained ~100% CPU, every thread parked in a `sample`, and roost's
12+
control socket accepting connections but never answering (`roost: connected,
13+
but no reply within 30s`). Each episode lasted 30s or more. Neither was
14+
captured: by the time `sample` ran the state was gone, and once the process
15+
had already exited on its own.
16+
17+
roost's response was PR navbytes/roost#183: an opt-in stall watchdog
18+
(`ROOST_WATCHDOG=1`) that captures a backtrace automatically next time. It
19+
does not explain the hang; this brief is about finding the cause.
20+
21+
## Evidence that survives
22+
23+
Only the default roost workspace's logs survive
24+
(`~/Library/Application Support/roost/`). The driven session ran against a
25+
different roost instance (its `control.log`, which records every control
26+
action unconditionally, has one request all day on 09-04), and that
27+
instance's isolated state dir is gone. So the surviving data describes the
28+
machine, not the wedged process.
29+
30+
From `perf.jsonl` (one line per minute: loop iterations, scheduling-stall
31+
histogram, 1-minute load average), local time +0800, 2026-09-04:
32+
33+
| time | load1 | loop iters/min | worst scheduling stall |
34+
|-------|--------|----------------|------------------------|
35+
| 07:43 | 7.1 | 1665 | 289 ms |
36+
| 07:48 | 129.5 | 1706 | 239 ms |
37+
| 08:13 | 81.2 | 1680 | 118 ms |
38+
| 21:09 | 13.4 | 1709 | 365 ms |
39+
| 23:55 | 115.3 | 1651 | 184 ms |
40+
41+
Normal is load1 around 5 to 8 and about 1650 iterations a minute. There is
42+
one hole in the log, 07:48:03 to 07:50:13, during the load spike: either a
43+
restart or a full minute with zero loop iterations. A second hole at 00:24
44+
on 09-05 coincides with a roost restart (lock file mtime), so holes are not
45+
proof of a wedge on their own.
46+
47+
The important fact is the load average. macOS load counts runnable threads.
48+
A value above 100 means CPU-bound work at scale, not processes waiting on a
49+
socket. Whatever happened at 07:48 and 23:55 was burning CPU across many
50+
threads or processes. Correlate these two timestamps with your own logs
51+
(`CTREE_DEBUG` files, `harness/pty-run.py` timing JSON, the isolated XDG
52+
profile's OpenCode server logs) to identify what was running.
53+
54+
## Hypotheses, ranked by fit to the evidence
55+
56+
### H1. Plugin-driven request storm saturates the OpenCode server
57+
58+
`src/shared/adopt.ts`: for every adoptable session, one `messagesOf` fetch,
59+
then up to `MAX_CANDIDATES = 40` more, sequential, per fork (`adopt.ts:24`,
60+
`:31`, `:56-68`). Both halves run it, on every `session.created` with three
61+
1-second retries (`src/server/index.ts:138-141`, `src/tui/index.tsx:99-102`),
62+
and again on `/tree` open and `/ctree status`. In the scale scenario
63+
(`scratchpad/perf-build.ts`, SIZES 50/100/200, forks auto-adopted) that is
64+
potentially thousands of SDK round-trips into one Bun server. None of the
65+
SDK calls carry a timeout or AbortSignal (`src/server/index.ts:109, 129, 180,
66+
190, 199, 233`; `src/tui/index.tsx:86`; `src/tui/transcripts.ts:54, 56`), so
67+
once the server is slow, callers pile up rather than fail.
68+
69+
Fits: the load average, the "100% CPU", and roost appearing wedged (a
70+
starved machine makes every process look wedged; parked threads in a
71+
`sample` are what starvation looks like).
72+
73+
Test: rerun the scale build with `CTREE_DEBUG` set and count SDK calls per
74+
`session.created`; record `uptime` every 5s and the OpenCode server's CPU
75+
(`top -pid <server>`) during the run; repeat with adoption stubbed out
76+
(`messagesOf` returning `[]`). Report calls per event, peak load1, peak
77+
server CPU, both ways.
78+
79+
### H2. Synchronous lock wait blocks a half's event loop
80+
81+
`src/shared/store.ts:126-158`: registry lock acquisition is a `for (;;)`
82+
loop around `fs.openSync(.., "wx")` with `sleepSync(5)` (`store.ts:157`,
83+
`:306`), bounded by `LOCK_TIMEOUT_MS = 250` (`store.ts:30`). Synchronous, so
84+
it blocks the JavaScript event loop of whichever half runs it. Bounded, so
85+
alone it cannot produce a 30s hang. Under H1-scale contention between the
86+
two halves it could stack up.
87+
88+
Test: log lock wait durations; count how many writes hit the 250ms deadline
89+
during the scale build. If it is rarely above a few ms, drop this one.
90+
91+
### H3. Hung LiteLLM connection
92+
93+
The plugin never references LiteLLM; the provider is configured at the
94+
OpenCode server level. A model request that never returns hangs OpenCode's
95+
turn and anything awaiting it, and the plugin's timeout-less SDK calls would
96+
hang with it. That explains an OpenCode "hang". It does not explain a load
97+
average above 100 (a blocked socket is not runnable), and it does not
98+
explain roost wedging (roost never talks to LiteLLM).
99+
100+
Test: point the isolated profile at a blackhole endpoint (a listener that
101+
accepts and never replies, e.g. a tiny Python server that sleeps forever),
102+
send one turn. Observe: does the TUI stay interactive? Does `/tree` open?
103+
Does load1 rise? Does roost keep answering `roost fleet list`? Expected if
104+
H3 is the whole story: TUI idle but responsive, load flat, roost fine.
105+
106+
### H4. roost's own bug
107+
108+
If H1 to H3 all reproduce cleanly without wedging roost, the wedge lives in
109+
roost. The watchdog exists for that case; see below.
110+
111+
## Run so evidence survives next time
112+
113+
- Launch roost with `ROOST_WATCHDOG=1 ROOST_DEBUG=1` and a dedicated
114+
`ROOST_STATE=<dir>`. Do not delete that dir after the run. It will hold
115+
`control.log`, `perf.jsonl`, `roost.log`, and on a stall `watchdog.log`
116+
plus `watchdog-<ts>.sample.txt` (a full all-threads backtrace).
117+
- Set `CTREE_DEBUG=<file>` for the plugin.
118+
- Run `opencode serve` as its own process so its CPU is visible separately
119+
from the TUI.
120+
- Record `uptime` every 5s to a file for the whole run.
121+
- At the first sign of a hang: `top -l 1 -o cpu | head -20`, then
122+
`sample <pid> 2 -f <file>` for the OpenCode server, the TUI, and roost.
123+
- Keep everything on one clock; note the time when the hang starts.
124+
125+
## Report back
126+
127+
For each of H1 to H3: reproduced or cleared, with the numbers the test
128+
asked for. If reproduced, the minimal trigger (session count, event) and
129+
the proposed fix (timeouts and AbortSignal on SDK calls, capping or
130+
batching adoption, an async lock wait, or moving lock waits out of hooks).
131+
If nothing reproduces, say so and hand H4 back to roost with the watchdog
132+
artifacts from the next occurrence.

0 commit comments

Comments
 (0)