Skip to content

fix(daemon): pause producers when stream backlogs grow - #20947

Merged
nwparker merged 6 commits into
mainfrom
np-oom-scan-daemon-stream-backpressure
Sep 20, 2026
Merged

nwparker merged 6 commits into
mainfrom
np-oom-scan-daemon-stream-backpressure

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​783 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​783
Prod 18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​889 $\color{#cf222e}{\Huge{\mathbf{−}}}$​132 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​757

ELI5

A terminal can produce text faster than a connected viewer can read it. Previously, unread text could keep piling up in memory. This change pauses the producer until the viewer catches up.

What Changed

Pause terminal producers when queued output crosses the existing pressure thresholds. Resume after the viewer drains the queue or its ownership ends. Output keeps its original order and is not intentionally discarded.

Why

Pausing preserves output while limiting memory used by a slow viewer. Dropping output would lose terminal text; disconnecting would interrupt the viewer. Disk spooling would require a separate storage and recovery design.

Tradeoff

A viewer that never catches up can keep its producer paused indefinitely. Providers without pause support retain their existing write-through fallback.

Linked Issue

Related to #19831 as memory hardening; this PR does not prove that incident was caused by stream retention.

Visual Proof

No before/after UI capture is attached; this is a behavioral change. Automated behavior evidence is linked under Testing.

Testing

The audit covers real TCP pressure, visible and hidden output, viewer detachment, resumption, and daemon lifecycle controls.

Audit evidence

Review

The intended behavior is backpressure, not data loss. Review provider pause support and the permanent-stall behavior before merging.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 37e0404a-7352-4342-b296-319b7ddaf6ce

📥 Commits

Reviewing files that changed from the base of the PR and between ca5aea1 and ba4fbe5.

📒 Files selected for processing (5)
  • docs/audits/daemon-stream-retention/README.md
  • docs/audits/daemon-stream-retention/reproduce.mjs
  • src/main/daemon/daemon-client-connections.ts
  • src/main/daemon/daemon-stream-data-batcher.test.ts
  • src/main/daemon/daemon-stream-data-batcher.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/daemon/daemon-stream-data-batcher.ts
  • docs/audits/daemon-stream-retention/README.md
  • src/main/daemon/daemon-stream-data-batcher.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The daemon stream pipeline now accounts retained entry bytes and centralizes session flushing. A backpressure coordinator tracks queued data, metadata, and pending writes with hysteresis, ownership checks, and stale-callback protection. Deep sockets hold eligible output and use refill coordination. Stream pauses integrate with client pauses. Tests and audit artifacts cover TCP backpressure, producer control, cleanup, and retention measurements.

Priority: ➖ Normal

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 17 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: pausing producers when daemon stream backlogs grow.
Description check ✅ Passed The description is mostly complete. It explains the user impact, mechanism, rationale, tradeoffs, linked issue, behavioral evidence, testing scope, and review focus. It omits several template checklis…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 17 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 6599f59f-9159-4458-bd08-6a409b764ae8

📥 Commits

Reviewing files that changed from the base of the PR and between 7f23d44 and ca5aea1.

📒 Files selected for processing (21)
  • docs/audits/daemon-stream-retention/README.md
  • docs/audits/daemon-stream-retention/after.json
  • docs/audits/daemon-stream-retention/before-1mib-chunks.json
  • docs/audits/daemon-stream-retention/before-64kib-chunks.json
  • docs/audits/daemon-stream-retention/reproduce.mjs
  • src/main/daemon/daemon-client-connections.ts
  • src/main/daemon/daemon-server.ts
  • src/main/daemon/daemon-stream-backpressure-socket.test.ts
  • src/main/daemon/daemon-stream-backpressure.test.ts
  • src/main/daemon/daemon-stream-backpressure.ts
  • src/main/daemon/daemon-stream-data-batcher.test.ts
  • src/main/daemon/daemon-stream-data-batcher.ts
  • src/main/daemon/daemon-stream-data-entry.ts
  • src/main/daemon/daemon-stream-data-split.ts
  • src/main/daemon/daemon-stream-entry-accounting.ts
  • src/main/daemon/daemon-stream-held-refill.ts
  • src/main/daemon/daemon-stream-keep-tail-drop.ts
  • src/main/daemon/session-producer-pause.ts
  • src/main/daemon/session-terminal-control.test.ts
  • src/main/daemon/session.ts
  • src/main/daemon/terminal-host.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Comment thread docs/audits/daemon-stream-retention/after.json
Comment thread docs/audits/daemon-stream-retention/reproduce.mjs Outdated
Comment thread src/main/daemon/daemon-client-connections.ts
Comment thread src/main/daemon/daemon-stream-data-batcher.test.ts Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one documentation nitpick inline and one design question below.

Reviewed changes

  • Producer backpressure tracker: new DaemonStreamBackpressure aggregates queued strings, per-entry metadata, and outstanding socket writes across clients, pauses a session's producer above a per-session high water while the global total exceeds 4 MiB, and resumes with hysteresis.
  • Batcher integration: DaemonStreamDataBatcher feeds backlog state into the tracker on every enqueue/flush/write completion; all socket writes route through it. Held-refill and entry accounting are extracted into DaemonStreamHeldRefill and daemon-stream-entry-accounting.ts; flushSession moves to daemon-stream-data-entry.ts.
  • Pause ownership: SessionProducerPause gains an independent streamBackpressured flag so the client pause/failsafe and daemon stream pressure cannot release each other; threaded through Session and TerminalHost, skipped when the session has no attached clients or is terminating.
  • Connection lifecycle: stream-socket replacement flushes the pending batch to the replacement socket; control/stream disconnect clears the batcher plus backpressure.
  • Tests + audit: real blocked-socket test (visible/hidden, 64 KiB and 1 KiB), tracker unit tests, metadata-accounting test, session pause-ownership tests, and a reproduction script with before/after measurements.

ℹ️ Stream-triggered pauses have no liveness bound

SessionProducerPause.pause('stream', …) deliberately skips the 5s failsafe, so the producer is only resumed by refresh() dropping below the low watermarks or by a clear/detach/termination. If a peer stops draining without closing the socket (half-open relay/TCP), the socket write callbacks stop firing, refresh() never observes a drop, and the producer can stay paused until the transport finally closes. Since the client here is main and the SSH-relay path is a supported deployment, please confirm there is a heartbeat/reconnect that bounds this, or document the accepted behavior.

Technical details
# Unbounded stream pause when the peer stops reading without closing

## Affected sites
- `src/main/daemon/session-producer-pause.ts:16` — `pause('stream', …)` returns after
  `setStreamBackpressured(true)` and never arms `failsafeTimer`.
- `src/main/daemon/daemon-stream-backpressure.ts:96` — the only `setPaused(sessionId, false)` paths
  require a `refresh()` where the per-session bytes fall to `SESSION_LOW_WATER_BYTES` or `pressured`
  clears; `refresh()` is driven by enqueues and socket write completions only.
- `src/main/daemon/daemon-server.ts:77` — wires the pause directly to `host.pauseProducer(id, 'stream')`.

## Required outcome
- The producer pause must be released within a bounded time even when a live socket stops draining,
  or the absence of such a bound must be a documented, deliberate tradeoff.

## Open questions for the human
- Does the stream transport (direct or SSH relay) guarantee close/error detection within a bounded
  window after main stalls, so `onStreamDisconnected` reliably clears the backpressure?
- If not, is a max-pause or periodic reconciliation refresh warranted?

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread docs/audits/daemon-stream-retention/README.md Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Incremental re-review of the commits since the prior pullfrog review (ca5aea1):

  • Stream-socket replacement resets per-socket-generation state: DaemonClientConnections.installStreamSocket now calls streamDataBatcher.replaceStream(clientId) before re-flushing, so a replaced socket's stale pending-write bytes and armed held-refill symbol can no longer keep the backpressure tracker pressured or block a re-arm on the replacement.
  • DaemonStreamDataBatcher.replaceStream: clears the batch timer, heldRefill, and the backpressure client entry without dropping queued payloads; the immediately following flush re-accounts them onto the new socket. This addresses the CodeRabbit stability finding on the replacement path.
  • Retention audit tightened: the README reproducer command now points at reproduce.mjs and marks the Node 26 measurements as historical (addressing the prior inline nit); reproduce.mjs asserts the visible producers paused and the hidden producer processed the full 8 MiB before emitting JSON.
  • Test-title fix: daemon-stream-data-batcher.test.ts uses %s instead of %i for the string payload placeholder.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Stream backpressure pauses a session's PTY with no deadline: the only
un-pause comes from the consumer draining, so a half-open peer that stops
reading without closing freezes the shell for the rest of the session.

Arm a 60s watchdog on the false->true stream-pause transition (not on the
re-assertions refresh() makes for neighbouring sessions). On fire, mark the
session stall-released: it becomes keep-tail droppable, its backlog is
thinned behind a dataGap, and the producer runs again. The existing dataGap
path makes the renderer restore that pane from the daemon's snapshot, so the
user sees the terminal jump to current rather than sit frozen. The mark
clears once the session's last byte leaves the daemon, restoring ordinary
pausing. Nothing here reports a process exit - loss of contact with a
consumer is not evidence about the child.

Also enable TCP keepalive on the stream socket so a genuinely dead peer
closes and onStreamDisconnected clears the pause.
`oxlint-disable-next-line` covers only the line directly after it, so a
rationale wrapped onto a second comment line suppressed nothing and the
casts failed the changed-code quality gate. Drop the remaining JSON.parse
cast for an annotated binding.
@nwparker
nwparker merged commit 84d827a into main Sep 20, 2026
34 checks passed
@nwparker
nwparker deleted the np-oom-scan-daemon-stream-backpressure branch September 20, 2026 00:52
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.

2 participants