fix: bound long-session memory, recover from stuck states, cap language servers - #96
Merged
Merged
Conversation
A session left running for hours climbed to several GB and was OOM-killed. Four unbounded paths, in the order they matter: - Snapshot.diffFull built every patch with infinite context, so a one-line edit to a 1MB file produced a ~1MB patch. That patch is stored on the message, written to SQLite, and pushed to every client, per turn. Files over the existing 2MB guard now use ordinary hunk context; the diff viewer keeps full-file context everywhere it was affordable. - SessionSummary.summarize is forked at every step-finish and each run hydrates the whole session and diffs the whole turn. Nothing serialised them, so several full copies of a session could be resident at once. Requests now collapse into one in-flight run plus one follow-up. - edit, write and apply_patch attached LSP.diagnostics() whole to tool metadata — every file every language server had ever opened, on every edit. Metadata now carries only the files the tool touched, which is all any reader of it looks up. - The TUI's V2 data provider mirrored every message of every session that produced an event, subagents included, for the life of the process, and nothing read it. It now mirrors a session only after something asks for that session's messages. README documents installing and upgrading through mise. Claude-Session: https://claude.ai/code/session_011zK7bzkqqTFRHCLD8xk4xY
`redcode` and even `redcode -v` could hang with no output and no error, and a worker-side throw left the TUI on an empty screen forever. Each of these is a wait with no deadline, or a failure with nowhere to go. - rpc: a handler that throws, or an unknown method, now answers with an error frame; the client rejects instead of leaving the promise pending. A worker that dies or fails to load rejects everything already waiting, so a broken server thread surfaces as an error rather than a blank UI. - flock: a lock whose owner is recorded as a pid on this host that no longer exists is stale now, not 60 seconds from now. Waiting out the heartbeat after a crash is a minute of a frozen app for nothing. - global: the module-level mkdir of the data directory runs before argv is parsed, so a stalled home mount hung `--version` too. It now says which path it is waiting on and lets the process continue. - bin/redcode: the Linux musl probe spawned `ldd` with no timeout while the darwin and windows probes next to it were already bounded. - tui: reading a non-TTY stdin to EOF blocked the first frame when the parent handed down a pipe nobody closes. - project: git during instance boot is bounded; a stalled credential helper no longer holds startup. - lsp: documents past an open-file cap are closed on the server and dropped, which also stops per-file text from growing for the life of the session. REDCODE_LSP_OPEN_FILE_LIMIT overrides the default of 200. Claude-Session: https://claude.ai/code/session_011zK7bzkqqTFRHCLD8xk4xY
…nto fix/bound-long-session-memory
- lsp: roots are resolved per file and eslint, oxlint and biome each treat a package-local config as a root, so a monorepo spawned one language server per package with nothing to stop it — hundreds of megabytes each, and rust-analyzer far more. Past the cap the least recently used client is shut down; it respawns on demand. REDCODE_LSP_MAX_CLIENTS overrides the default of 8. - npm: reify runs under a cross-process install lock whose heartbeat keeps refreshing while it waits, so a registry connection that never answers looks alive forever and every other process waits out the full lock timeout. It now fails after a deadline the caller can report. - prompt: the editor keeps whatever whitespace was left around the message — a trailing newline from shift+enter, indentation from an abandoned edit — and it was all sent verbatim. Whitespace-only input was sent as an empty message. The guard and the payload now share one helper so they cannot disagree. Claude-Session: https://claude.ai/code/session_011zK7bzkqqTFRHCLD8xk4xY
filipeforattini
added a commit
that referenced
this pull request
Sep 4, 2026
* ci: run every package's tests, not five of them turbo.json declared `test` for five task names, one of which — `opencode#test` — stopped matching anything at the rename. The main package and the TUI have not run in CI since: a regression I shipped in #96 broke three TUI tests and nothing noticed until I ran them by hand. Declare the task once so every package that has a test script runs it, and raise the job timeout to match the new scope. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * ci: run every package's tests, and fix the two failures that hid there The turbo `test` task was declared for five names, one of which — `opencode#test` — stopped matching anything at the rename, so the main package and the TUI have not run in CI since. Declaring it once covers all 30 suites. Turning them on surfaced two real defects, both mine: - mise detection matched any `mise/installs` path, so a machine whose Bun comes from mise called every install mise-managed; - a source-text assertion pinned the exact shape of the worker's `env` literal and broke when #103 added a key, while saying nothing about whether the environment still reaches the worker. Keep `^build` only on the four tasks that already had it: the generic task made every test run build the release binaries. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * test: fix what turning the suites on revealed - `redcode run`: #98 made an unknown finish continue the turn instead of ending it in silence, which is the recovery we want; the two tests still pinned the old shape. They now assert the recovery and what must not change with it. - `httpapi-codegen`: expectations pinned "/" separators and read a fixture directory through `URL.pathname`, which is "/C:/..." on Windows. Neither had ever run there. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * test: make the newly-running suites pass on machines other than a quiet Linux box Everything here failed for a reason unrelated to what the test was checking: - TUI frame captures gave up after 125 ms and command registration after 250 ms, so the whole-suite run read a blank frame as a broken component; - `tool.write` asserted 0644, which is only what a umask of 022 produces — forcing the mode would be worse than the bug, so the test now follows umask; - the codegen fixture compared bytes across a CRLF checkout; - the webfetch converter test builds 10,000 nested divs on purpose and the embedded-server suite boots a server per test, both against a 5 s default. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * test: resolve effect through the module graph, not this package's node_modules `packages/client/node_modules` does not exist on the Windows runner — the installer hoists to the workspace root — so the file threw at import time and the whole suite failed before a test ran. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * test: stop pinning POSIX separators in TUI path assertions Both tests build paths with `path`, and both asserted the result with a hard-coded "/". They described a POSIX machine, which is the only kind they had ever run on. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b * test: build the SDK before the CLI tests, and call quoted binaries the way PowerShell needs - `@reddb-io/redcode#test` spawns the real CLI, which reads generated SDK sources; without `^build` it read a file that had not been generated yet. - One shell test built a quoted command without the call operator that the helper beside it already applies, so PowerShell parsed the path as a string and the first flag as a syntax error. Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Três problemas relatados em uso real, com a mesma raiz: nada tinha teto. Memória que só cresce, esperas sem prazo, e processos que ninguém conta.
Parte 1 — sessão longa consome vários GB e morre por OOM
Sessão de ~2h num monorepo Rust+TS, contexto do modelo em 53k tokens (4%), processo morto pelo OOM killer (exit 137).
Snapshot.diffFullgerava o patch comcontext: MAX_SAFE_INTEGER, então uma linha alterada num arquivo de 1 MB virava patch de 1 MB. Medido: 2,2 MB de patch para uma linha. Esse patch é gravado na mensagem, no SQLite e enviado a todos os clientes, a cada turno. Acima do guard de 2 MB que já existia, agora usa contexto de hunk normal.summarizerodando a cada passo do modelo, não por turno. Cada execução reidratava a sessão inteira e refazia o diff, sem serialização, então várias cópias completas ficavam residentes juntas. Agora colapsa em uma execução em voo mais uma de acompanhamento.Parte 2 — travar sem mensagem, sem cura automática
redcodee atéredcode -vtravando sem saída e sem erro.mkdirde topo emglobal.tsroda antes do argv ser lido, então um home num mount parado travava até o--version. Agora diz qual caminho está preso e segue.bin/redcodechamavalddsem timeout no Linux, enquanto as sondas de macOS e Windows ao lado já eram limitadas. É a causa mais provável do-vtravado no WSL.npm reifysegurava um lock entre processos durante uma instalação de rede sem prazo, e o heartbeat continuava ativo, então o breaker de obsolescência nunca disparava: um processo travado ali parecia vivo e fazia todos os outros esperarem o timeout inteiro. Agora falha por prazo.Parte 3 — processos e estado sem teto
REDCODE_LSP_MAX_CLIENTSajusta, padrão 8.REDCODE_LSP_OPEN_FILE_LIMITajusta, padrão 200.Parte 4 — trim da mensagem digitada
O editor mantinha o espaço em branco ao redor da mensagem, e ele ia junto: uma quebra de linha do shift+enter, indentação de uma edição abandonada. Entrada só de espaço era enviada como mensagem vazia. Agora o guard e o texto enviado passam pelo mesmo helper, então não podem divergir.
Verificação
Todo teste novo foi verificado em vermelho antes do verde:
bun typecheck(turbo, 31 pacotes) verde. Suítes de core (1143), lsp, util, project, session, tool, snapshot e prompt verdes.Falhas locais que reproduzem igual no
mainlimpo, não relacionadas: teste de permissão dowritenesta máquina com umask 0002,acp lifecycle stdin EOF, e timeouts deprompt/compactionsob carga (load average 7 aqui; passam isolados).Não incluído
Levantado com evidência, mas fora deste PR: o keyspace por sessão em
sync.tsxnunca libera sessões antigas, e cada card de subagente renderizado puxa 100 mensagens; e a fila SSE emhandlers/event.tsé ilimitada, enquanto a outra implementação do repo usaallBounded(256).https://claude.ai/code/session_011zK7bzkqqTFRHCLD8xk4xY