DeepSeek Harness web OOM in ~50 min: memory leak fixes

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginweb memory leakOOMlarge session
DeepSeek Harness web freezes on a large old session, or OOMs after ~50 minutes? Cold history materialization plus four unbounded accumulators.

DSH plugin web shows two memory symptoms: opening one large old session freezes the entire service (even GET / times out), or about 50 minutes of use ends in FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory — the cause is cold-history full materialization stacked on four unbounded accumulators. Both happen on the single Node main thread shared with the web server, which is why the symptom is usually "the whole UI is unresponsive" rather than one broken session. Physically moving large session directories out of sessions/ and keeping a single instance running is the most effective mitigation today.

DSH plugin symptoms: frozen cold session and OOM after ~50 minutes

The same design gap fires on two time scales: one-shot materialization when a history is opened, and steady accumulation while the process runs. Two community reproductions:

  1. Cold history pins the main thread: dsh web prints http://127.0.0.1:3080 and the listener exists, but HTTP requests time out at the ten-second mark (HTTP_ERROR after 10185ms); during the stall the node working set was ~163 MB, CPU was near saturation, and multiple CLOSE_WAIT connections piled up on port 3080. An A/B test isolated it to history loading: after moving the two affected session trees out of the active root and restarting, the first GET / returned in 243 ms, warm requests in ~17 ms, at 0.02 CPU seconds (#1550).
  2. Heap hits 4 GB and crashes at ~50 minutes: GC logs show the heap climbing from 4.0 GB to 4067/4090 MB over about 3024 seconds, with a single Mark-Compact taking 3.1 seconds during which the service was unresponsive, and exit code 134. Both 0.1.0-rc.5 and 0.1.1-rc.2 reproduce it (#3876).
  3. Boot materializes too: with 15 valid sessions (1.8–2.7 MB compressed each, ~300k tokens) in sessions/<cwd>/, dsh web climbed to ~3 GB of heap within 60–90 seconds with zero client interaction. About 60% of CPU samples landed in structuredClone, and a heap snapshot showed 21.6 million live objects (Object 526 MB, strings 329 MB, plus assistant/chunk, reasoning-delta, and similar event objects).
  4. On Linux the growth shows up as RSS, not JS heap: on 0.1.2-rc.1, a heavy 20-minute window produced five crashes; a live-process capture showed the V8 heap flat (213→206 MB) while RSS grew from 430 MB to 1.16 GB, with ~737 MB in glibc [heap] and allocation stacks in ZSTD_decompressStream and JsonStringifier::Extend. After the load stopped, RSS fell back from 1190 MB to 390 MB on its own.

DeepSeek Harness's four unbounded accumulators plus cold-history materialization

The write side has four containers that only grow, and the read side decodes an entire log before paginating; both share one event loop. Point by point:

  1. FrameQueue.buffer has no cap (packages/host/apiproxy/src/api-proxy.ts): push() enqueues unconditionally with no backpressure or dropping, one queue per mux/host subscriber, and each session/event frame is copied into every subscriber queue. When a browser tab is suspended or consumes slowly (TCP still open), frames accumulate without limit, each holding a full event reference.
  2. Session.log is append-only (packages/core/session/src/index.ts): the event array never trims, so a long session's events stay resident forever.
  3. Projection cache rows are never deleted (packages/session/session-projection-cache/src/index.ts): on session/disposed it only calls markClean(session) and dirty.delete(session), with no delete on the underlying table; KvTable is a fully resident in-memory Map whose row count grows with the number of past sessions.
  4. SessionWriteBehind.pending keeps failed writes forever (packages/session/session-persistence/src/write-behind.ts): this.pending = batch.concat(this.pending) retains the whole failed batch for retry with no cap, and every event is structuredCloned.
  5. The read-side materialization chain (#1550): historySourceFor() calls sessionPersistence.inspect(sessionId) and gets the full event array before paginate runs; underneath, readPrefixreadZstdPrefix decodes every frame and scanLog parses the entire buffer; then prepareCoreadoptStoredEvents migrates and deep-freezes every event; then Session.create/fromRestoreadoptSessionEvent(structuredClone(event)) clones each one. A single event is deep-cloned at least 2–3 times per prepare, on top of the full seed copy seed: loaded.events.map((event) => structuredClone(event)) (dsh-session-persistence lib/index.js:1390 and :1586, still present in 0.1.2-rc.1).
  6. The per-read cost is measurable: replicating readRaw in an isolated process for the largest session (45 MB zstd → 128 MB plaintext) peaked at 647 MB RSS, with the Buffer.concat + toString("utf8") step jumping from 296 MB to 647 MB. Materializing two or three such sessions at once is enough to blow through a 2.1 GB default ceiling.
  7. The same path explains search crashes on large logs: stringifying a whole log in one call exceeds V8's string limit and raises RangeError: Invalid string length — the same family, covered in a separate article.

DSH plugin mitigation and patch status: move big sessions out, keep one instance

The most effective mitigation shrinks the materialization surface: physically move unused large session directories out of sessions/, and make sure only one harness instance is running. From cheapest to most involved:

  1. Move large session directories out physically (not merely archive them). Measured: 15 large sessions dropped from ~3 GB heap to ~306 MB with GET / back to 0.2 s; on Linux, moving 106 archived sessions (385 MB total, including 45/36/35 MB ones) out left the largest remaining session at 5 MB and the service stable. archivedSessionIds is not a materialization boundary — archived sessions stay on disk and can still be loaded.

  2. Keep a single writer. Multiple processes writing the same session invite log corruption (the seq gap family), and a corrupt log re-enters the same full-scan path, amplifying a small problem into a site-wide stall; see session corruption troubleshooting.

  3. Identify the offending session offline first. Community session-doctor tools use their own loopback API, read headers only, and decode on demand, so they never trigger the official history materialization path (for example dsh-session-surgeon, installed like any other plugin):

    sh
    dsh plugin --profile web add github:xiaoshenming/dsh-session-surgeon
    

    Installing through DSH Plugin Hub (Settings → Plugin Marketplace) is preferred: a failed install rolls the manifest back instead of leaving the profile broken.

  4. Be careful with --max-old-space-size. Raising the limit from 2 GB to 4 GB makes V8's GC lazier, and a cgroup peak of 7.3 GB was observed — on memory-constrained machines with swap pressure this trades an immediate crash for a higher water mark, not a fix.

  5. Budget for the client too. Beyond server-side materialization, the conversation view renders everything with no virtualization, so a long heavy session inflates the browser renderer independently (a Safari/WebKit renderer reached 11.27 GB RSS while the session log was only 796 KB; a fresh renderer was 0.55 GB). The same events pressure both ends.

  6. Patch status: two community reference implementations exist — a fork branch fix/session-history-responsiveness (cross-process writer lease, Zstd decode scheduling slice cut from 500 ms to 16 ms, plus revision-scoped caching of deterministic read failures so an unchanged corrupt file stops being rescanned) and a bounded cold-history read patch (pageSurfaceMessages, readHistoryWindow, historyWindowMaxEvents, with the JSONL backend doing a two-pass streaming scan that keeps only the requested page). As of 0.1.2-rc.1, the full cold-start structuredClone seed has not shipped in a release.

DSH plugin troubleshooting notes

Separate "freezes on open" from "crashes after a while" — the mitigation overlaps but the evidence differs, and the first instinct should be to hunt for a large session rather than blame networking or ports. Five points to keep in mind when a DeepSeek Harness plugin web server grows without bound:

  1. Separate "freezes on open" from "crashes after a while": the first is cold-history materialization, the second unbounded accumulation. Watch GET / latency and CPU for the first, GC logs and heap curves for the second.
  2. Archiving is not isolation: the real boundary is whether the files are inside the sessions/ directory.
  3. Rising RSS is not automatically a leak: the gdb and malloc_trim evidence shows a large share is allocator high-water mark; track both the V8 heap and RSS.
  4. One bad session can stall everyone: that is the amplifier here, so when the whole UI goes unresponsive, look for a large or corrupt session before suspecting networking or ports.
  5. Back up before moving anything: confirm no process holds the session, and keep a record of the original paths so you can move it back.
DSH Plugin Hub plugin market: install session diagnostics plugins, check versions and updates

Sources: Discussion #3876, Discussion #1550, Discussion #1859.

FAQ

Why does DeepSeek Harness web report JavaScript heap out of memory after about 50 minutes?

In a DSH plugin web server, about 50 minutes of use ends in JavaScript heap out of memory, and four unbounded accumulators were located: FrameQueue.buffer in api-proxy.ts has no cap and copies every frame into every subscriber queue; Session.log in core/session is append-only; session-projection-cache marks rows clean on session/disposed but never deletes them (the underlying KvTable stays resident); and write-behind's pending keeps an entire failed batch forever with a structuredClone per event. Both 0.1.0-rc.5 and 0.1.1-rc.2 reproduce it (source: Discussion #3876).

Why does opening one large old session freeze the whole DeepSeek Harness web UI, even GET /?

In DeepSeek Harness, a cold session's historySourceFor() calls sessionPersistence.inspect() and receives the complete event array before paginate() runs. maxMessages bounds only the payload sent to the client, not Zstd decompression, JSON parsing, validation, or event materialization — all of which run on the same Node main thread as the web server. One measured 461,981-event session (16.5 MB decoded) made GET / time out while CLOSE_WAIT connections piled up on port 3080 (source: Discussion #1550).

How do I mitigate the growth in a DSH plugin, and why does archiving sessions into archivedSessionIds not help?

In a DSH plugin, archivedSessionIds is a display-level filter: archived large sessions stay in the sessions/ directory and are still fully materialized. Controlled experiment: 15 large sessions brought boot heap to ~3 GB with GET / timing out; physically moving 11 session directories out of sessions/<cwd>/ brought heap to a stable ~306 MB and GET / back to 0.2 s. Setting preparedSessionCacheSize to 1 does nothing either — it is a retention knob, not a materialization gate (source: Discussion #1550).

Is the DSH plugin memory issue fixed in recent builds, and does raising --max-old-space-size help?

The DSH plugin memory issue is not fixed as of 0.1.2-rc.1: packing the published dsh-session-persistence tarball still shows the full seed deep copy, seed: loaded.events.map((event) => structuredClone(event)) (lib/index.js:1390 and :1586), and a Linux user saw five crashes in 20 minutes on 0.1.2-rc.1. Raising --max-old-space-size only trades a crash for a higher peak (a cgroup peak of 7.3 GB was observed); it is not a treatment. Community patches for writer leasing and bounded cold-history reads are in progress (source: Discussion #3876).

Related Terms

cold history
A past session that is not in the current process's memory. Opening it requires reading Zstd records from disk, decoding, parsing, validating, and materializing events, with a cost proportional to log size.https://github.com/deepseek-ai/deepseek-harness/discussions/1550
backpressure
Slowing, bounding, or coalescing a producer when the consumer (for example a browser tab) cannot keep up. The publicly discussed FrameQueue has no backpressure, so a slow consumer lets frames accumulate without limit.https://github.com/deepseek-ai/deepseek-harness/discussions/3876
allocator high-water mark
Memory that was freed but not returned to the operating system, so RSS only grows. One measurement showed RSS dropping from 5.48 GB to 549 MB after malloc_trim, meaning much of the growth was a high-water mark rather than a resident leak.https://github.com/deepseek-ai/deepseek-harness/discussions/3876

Sources