You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 — "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/hydratingSessions are deleted
on completion) and the worker→TUI event forwarding (one Rpc.emit per 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 + offerUnsafe for 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 recent RECENT_MESSAGE_WINDOW (40) render as a one-line CollapsedMessage
placeholder until clicked. Because Solid disposes the losing Match branch, 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 UserMessage does); 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-mutated text field (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 animated Spinner re-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 EventRetention sweep already does that.
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's OPENCODE_AUTO_HEAP_SNAPSHOT tool (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.
RECENT_MESSAGE_WINDOW is a constant — now the transcript_window TUI config key
(packages/tui/src/config/index.tsx, default 40, 0 never collapses), read through useTuiConfig() exactly like scroll_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 to store.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.
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: CollapsedMessage for 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 of structuredClone, sessionID preserved
1.4 Reasoning header: static while open, spinner only while collapsed
1.5 Register all edited upstream files in fork/manifest.json with markers
1.6 Make the window configurable the idiomatic way: transcript_window in the TUI
config schema (TranscriptWindow + TranscriptWindowDefault, like LeaderTimeout),
read via useTuiConfig() like scroll_speed; 0 never collapses. No migration entry
needed (that file maps legacy keys only).
Phase 2: Verify
2.1 bun typecheck clean in packages/opencode and packages/tui
2.2 packages/opencode session suite green (a first run's 138 failures were my own
dropped sessionID field, fixed; one subsequent failure did not reproduce — flaky). packages/tui suite: 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.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=true so a crash leaves a heap snapshot next time
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 retentionsweep, 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:
structuredClone(part). Our processorpublishes a small
PartDeltaper token for both text and reasoning; the full-partupdatePart(with the clone) fires only at part start/end and tool state changes. O(parts),not O(tokens²).
EventTargetlistener leak. ZeroMaxListenersExceededWarninghits across all 12 of the user's real log files.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.
syncingSessions/hydratingSessionsare deletedon completion) and the worker→TUI event forwarding (one
Rpc.emitper event, no queue).<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.
handlers/event.ts,Queue.unbounded+offerUnsafefor every event before filtering; upstream perf: bound instance SSE subscriber queues anomalyco/opencode#45215). Itaffects 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
packages/tui/src/routes/session/index.tsx): messages older thanthe most recent
RECENT_MESSAGE_WINDOW(40) render as a one-lineCollapsedMessageplaceholder until clicked. Because Solid disposes the losing
Matchbranch, 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.handlers/event.ts):EventV2.allBounded(core'sexisting dropping-queue pattern) with capacity 10,000; on overflow the stream fails and the
client reconnects and resyncs — the recoverable outcome.
updatePart(session/session.ts):{ ...part }instead ofstructuredClone(part). Same snapshot for the in-place-mutatedtextfield (strings areimmutable;
+=reassigns), without copying part bytes per publish. Safe: every consumerdestructures synchronously (projector), clones for itself (
share-next), or receives analready-serialized copy (SDK/SSE). Minor in this fork; kept because it is strictly cheaper
and upstream-validated.
the animated
Spinnerre-laid out the expanded reasoning body every frame. Static headerwhile open, spinner only while collapsed.
Non-Goals
EventRetentionsweep already does that.Open
multi-hour session. The honest proof is RSS over a long real session before/after. The
fork's
OPENCODE_AUTO_HEAP_SNAPSHOTtool (heap snapshot when RSS > 2 GB) was never enabledduring 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 throughuseTuiConfig()exactly likescroll_speed. Set it in.opencode/tui.json.a live session appends to
store.message[sessionID]/store.part[...]without a cap. Rawmessage 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
OOM crash on startup: Event table snapshot + session revert data grow to 1.4GB, sidecar V8 process runs out of memory anomalyco/opencode#38362) — see
proposal.mdfor what was and was not presentafter 2 s idle); an allocator knob made it worse; growth is therefore live retention
subscriber queue (server, HTTP clients)
Phase 1: Fix
CollapsedMessagefor messages older thanRECENT_MESSAGE_WINDOW, per-item expand state, id preserved for prompt navigationEventV2.allBounded, capacity 10,000updatePart: shallow copy instead ofstructuredClone,sessionIDpreservedfork/manifest.jsonwith markerstranscript_windowin the TUIconfig schema (
TranscriptWindow+TranscriptWindowDefault, likeLeaderTimeout),read via
useTuiConfig()likescroll_speed;0never collapses. No migration entryneeded (that file maps legacy keys only).
Phase 2: Verify
bun typecheckclean inpackages/opencodeandpackages/tuipackages/opencodesession suite green (a first run's 138 failures were my owndropped
sessionIDfield, fixed; one subsequent failure did not reproduce — flaky).packages/tuisuite: 209 pass, 9 pre-existing failures, all in the sync-store anddiff-viewer test harnesses ("Permission context must be used within a context
provider"), none of which import the session route or anything changed here.
1.18.18-dev+e3bfe849f7-dirty.20260917T223143Z(includestranscript_window)expands one and it stays open, prompt navigation still lands on user prompts, no
spinner shake while an expanded thinking block is streaming
OPENCODE_AUTO_HEAP_SNAPSHOT=trueso a crash leaves a heap snapshot next timePlan changes
13 done