Skip to content

fix: bound long-session memory, recover from stuck states, cap language servers - #96

Merged
filipeforattini merged 5 commits into
mainfrom
fix/bound-long-session-memory
Sep 3, 2026
Merged

fix: bound long-session memory, recover from stuck states, cap language servers#96
filipeforattini merged 5 commits into
mainfrom
fix/bound-long-session-memory

Conversation

@filipeforattini

@filipeforattini filipeforattini commented Sep 3, 2026

Copy link
Copy Markdown

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

  • Diff de turno com contexto infinito. Snapshot.diffFull gerava o patch com context: 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.
  • summarize rodando 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.
  • Diagnósticos do LSP inteiros no metadata de cada edição. Era todo arquivo que qualquer language server já abriu, clonado, persistido e enviado a cada cliente. Agora vai só o que a ferramenta tocou, que é o que todo leitor consulta.
  • A TUI espelhava toda mensagem de toda sessão que gerasse evento, subagentes incluídos, pela vida do processo, e nada lia isso. Agora só espelha depois que alguém pede as mensagens da sessão.

Parte 2 — travar sem mensagem, sem cura automática

redcode e até redcode -v travando sem saída e sem erro.

  • RPC do worker engolia falhas. Um handler que lança não respondia nada e o chamador esperava para sempre, e o worker tinha handlers vazios para exceção não tratada. Agora erro vira quadro de erro, o cliente rejeita, e um worker que morre ou não carrega rejeita tudo que está pendente. É a correção de maior alavancagem: transforma quase todo congelamento em erro visível.
  • Lock de processo morto. Só era considerado obsoleto após 60 s de heartbeat. Agora, se o dono registrado é um pid desta máquina que não existe mais, o lock é tomado na hora.
  • mkdir de topo em global.ts roda 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/redcode chamava ldd sem timeout no Linux, enquanto as sondas de macOS e Windows ao lado já eram limitadas. É a causa mais provável do -v travado no WSL.
  • stdin não-TTY lido até EOF bloqueava o primeiro frame quando o pai entrega um pipe que ninguém fecha.
  • git no boot da instância sem prazo. Um helper de credencial parado segurava o startup inteiro.
  • npm reify segurava 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

  • Nada limitava a quantidade de language servers. Roots são resolvidos por arquivo, e eslint, oxlint e biome tratam config local de pacote como root, então um monorepo subia um servidor por pacote, cada um com centenas de MB, e rust-analyzer muito mais. Agora, acima do teto, o menos usado é desligado e volta sob demanda. REDCODE_LSP_MAX_CLIENTS ajusta, padrão 8.
  • O LSP guardava o texto de todo arquivo aberto para sempre. Agora fecha documentos acima do teto por cliente. REDCODE_LSP_OPEN_FILE_LIMIT ajusta, 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:

  • patch de arquivo grande: 2,2 MB sem o fix, abaixo de 4 KB com;
  • coalescência do summarize: pegou um bug real na primeira versão, uma chave presa após interrupção;
  • lock de dono morto: 20 s e falha sem o fix, imediato com;
  • RPC: handler que lança, método desconhecido e morte do worker, todos rejeitando;
  • despejo de documentos e teto de clientes do LSP: 3 clientes sem o fix, 2 com;
  • trim da mensagem.

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 main limpo, não relacionadas: teste de permissão do write nesta máquina com umask 0002, acp lifecycle stdin EOF, e timeouts de prompt/compaction sob 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.tsx nunca libera sessões antigas, e cada card de subagente renderizado puxa 100 mensagens; e a fila SSE em handlers/event.ts é ilimitada, enquanto a outra implementação do repo usa allBounded(256).

https://claude.ai/code/session_011zK7bzkqqTFRHCLD8xk4xY

filipeforattini and others added 4 commits September 3, 2026 14:27
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
@filipeforattini filipeforattini changed the title fix(session): bound memory growth in long sessions fix: bound long-session memory and recover from stuck states Sep 3, 2026
- 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 filipeforattini changed the title fix: bound long-session memory and recover from stuck states fix: bound long-session memory, recover from stuck states, cap language servers Sep 3, 2026
@filipeforattini
filipeforattini merged commit 9988bc7 into main Sep 3, 2026
7 checks passed
@filipeforattini
filipeforattini deleted the fix/bound-long-session-memory branch September 3, 2026 20:37
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant