|
| 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