Repository navigation
Stop a long-running session from growing until the machine dies #86
Copy link
Copy link
Open
Description
Proposal
Why
A long-running opencode-skein TUI session grows in memory until macOS kills the machine —
the user's M3 Mac has been taken down by OOM several times. Upstream has ~20 open OOM/leak
issues and has fixed none of them since this fork's baseline; the fork had already bounded
one growth vector (session-summary-write-amplification) and has an event-journal retention
sweep, but the process itself still climbs.
What was actually found (2026-09-18)
Investigated against this codebase and this machine, not from the issue titles:
- Not the cause here — upstream fix(session): Memory keeps growing until the bun process is killed. anomalyco/opencode#35107's per-token
structuredClone(part). Our processor
publishes a smallPartDeltaper token for both text and reasoning; the full-part
updatePart(with the clone) fires only at part start/end and tool state changes. O(parts),
not O(tokens²). - Not the cause here — upstream Memory leak in AI SDK Effect runtime — EventTarget listeners never cleaned up anomalyco/opencode#34574's Effect
EventTargetlistener leak. Zero
MaxListenersExceededWarninghits across all 12 of the user's real log files. - Not the cause here — "Bun never returns freed pages." Measured: streaming-shaped churn
took RSS 76→175 MB; after release and 2 s idle it fell to 57 MB. Memory does return on this
Bun. An allocator env knob made it worse. So a real long-session climb is live
retention, not unreturned pages — which is what made the remaining candidates decisive. - Not the cause — sync-store bookkeeping (
syncingSessions/hydratingSessionsare deleted
on completion) and the worker→TUI event forwarding (oneRpc.emitper event, no queue). - Real, TUI side — the unbounded rendered transcript.
<scrollbox><For each={messages()}>
with no windowing: every message and every part of the session stays mounted as rendered
nodes — markdown, syntax highlighting, code blocks, and the hundreds-of-KB reasoning dumps
local/reasoning models produce — for the session's whole life. The TUI is one process (its
server is a Worker thread), so this and the server-side growth land in the same PID. The
user's DB holds 91,569 parts / 231 MB of raw part text; rendered trees are far larger than
raw text. This is the one unbounded retainer left standing and the primary fix. - Real, server side — the unbounded per-subscriber SSE queue (
handlers/event.ts,
Queue.unbounded+offerUnsafefor every event before filtering; upstream perf: bound instance SSE subscriber queues anomalyco/opencode#45215). It
affects HTTP clients (web/desktop), not the default TUI's worker-RPC feed, but a stalled
client retains every event in the process forever.
What Changes
- Transcript windowing (
packages/tui/src/routes/session/index.tsx): messages older than
the most recentRECENT_MESSAGE_WINDOW(40) render as a one-lineCollapsedMessage
placeholder until clicked. Because Solid disposes the losingMatchbranch, old messages'
heavy renderers are unmounted, not hidden. The transcript stays fully scrollable; keyboard
prompt-navigation keeps working (placeholders carry the message id exactly where
UserMessagedoes); a message the user opens stays open. - Bounded SSE subscriber queue (
handlers/event.ts):EventV2.allBounded(core's
existing dropping-queue pattern) with capacity 10,000; on overflow the stream fails and the
client reconnects and resyncs — the recoverable outcome. - Shallow part copy in
updatePart(session/session.ts):{ ...part }instead of
structuredClone(part). Same snapshot for the in-place-mutatedtextfield (strings are
immutable;+=reassigns), without copying part bytes per publish. Safe: every consumer
destructures synchronously (projector), clones for itself (share-next), or receives an
already-serialized copy (SDK/SSE). Minor in this fork; kept because it is strictly cheaper
and upstream-validated. - Reasoning spinner shake (same TUI file, unrelated to memory, user-reported alongside):
the animatedSpinnerre-laid out the expanded reasoning body every frame. Static header
while open, spinner only while collapsed.
Non-Goals
- No pruning of the event journal — the fork's
EventRetentionsweep already does that. - No allocator tuning — measured not to help here.
- No fix for upstream Memory leak in AI SDK Effect runtime — EventTarget listeners never cleaned up anomalyco/opencode#34574 — its signature is absent on this machine.
Open
- The primary fix is verified by typecheck and the existing suites, not yet by a measured
multi-hour session. The honest proof is RSS over a long real session before/after. The
fork'sOPENCODE_AUTO_HEAP_SNAPSHOTtool (heap snapshot when RSS > 2 GB) was never enabled
during the crashes, so no snapshot exists; it should be on for the next long session. — now theRECENT_MESSAGE_WINDOWis a constanttranscript_windowTUI config key
(packages/tui/src/config/index.tsx, default 40,0never collapses), read through
useTuiConfig()exactly likescroll_speed. Set it in.opencode/tui.json.- The sync store already caps hydration to a 100-message window (its own tests say so), but
a live session appends tostore.message[sessionID]/store.part[...]without a cap. Raw
message and part data is far smaller than rendered nodes, so this is second-order — but for
a truly marathon session it is the next unbounded thing, and capping live retention to the
same window is the natural follow-up.
Tasks
Phase 0: Find the cause, not the issue title
- 0.1 Check each diagnosed upstream cause against this codebase (fix(session): Memory keeps growing until the bun process is killed. anomalyco/opencode#35107, Memory leak in AI SDK Effect runtime — EventTarget listeners never cleaned up anomalyco/opencode#34574, perf: bound instance SSE subscriber queues anomalyco/opencode#45215,
OOM crash on startup: Event table snapshot + session revert data grow to 1.4GB, sidecar V8 process runs out of memory anomalyco/opencode#38362) — seeproposal.mdfor what was and was not present - 0.2 Check the user's real logs for the Memory leak in AI SDK Effect runtime — EventTarget listeners never cleaned up anomalyco/opencode#34574 signature — zero hits in 12 files
- 0.3 Measure whether freed memory returns to the OS on this Bun — it does (175→57 MB
after 2 s idle); an allocator knob made it worse; growth is therefore live retention - 0.4 Locate the unbounded retainers — rendered transcript (TUI, primary) and SSE
subscriber queue (server, HTTP clients)
Phase 1: Fix
- 1.1 Transcript windowing:
CollapsedMessagefor messages older than
RECENT_MESSAGE_WINDOW, per-item expand state, id preserved for prompt navigation - 1.2 Bound the SSE subscriber queue with
EventV2.allBounded, capacity 10,000 - 1.3
updatePart: shallow copy instead ofstructuredClone,sessionIDpreserved - 1.4 Reasoning header: static while open, spinner only while collapsed
- 1.5 Register all edited upstream files in
fork/manifest.jsonwith markers - 1.6 Make the window configurable the idiomatic way:
transcript_windowin the TUI
config schema (TranscriptWindow+TranscriptWindowDefault, likeLeaderTimeout),
read viauseTuiConfig()likescroll_speed;0never collapses. No migration entry
needed (that file maps legacy keys only).
Phase 2: Verify
- 2.1
bun typecheckclean inpackages/opencodeandpackages/tui - 2.2
packages/opencodesession suite green (a first run's 138 failures were my own
droppedsessionIDfield, fixed; one subsequent failure did not reproduce — flaky).
packages/tuisuite: 209 pass, 9 pre-existing failures, all in the sync-store and
diff-viewer test harnesses ("Permission context must be used within a context
provider"), none of which import the session route or anything changed here. - 2.3a Rebuilt:
1.18.18-dev+e3bfe849f7-dirty.20260917T223143Z(includes
transcript_window) - 2.3b Use the TUI: old messages collapse past 40 and show a one-line summary, click
expands one and it stays open, prompt navigation still lands on user prompts, no
spinner shake while an expanded thinking block is streaming - 2.4 The real proof: RSS sampled over a multi-hour session before vs. after, with
OPENCODE_AUTO_HEAP_SNAPSHOT=trueso a crash leaves a heap snapshot next time
Plan changes
13 done
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
fix(tui): stop long-running sessions from growing until the machine dies
fix(tui): stop long-running sessions from growing until the machine dies
fix(tui): stop long-running sessions from growing until the machine dies