Skip to content

Desktop freezes when a terminal closes right after a burst of output #9450

Open

Description

@Bruno-rose

Where did this happen?

Desktop app (macOS)

Version

Source build, main at 19db630

What happened?

HistoryWriter.close() can loop forever on the Electron main thread, which freezes the whole app. It happens when a terminal closes (process exit, clear scrollback, or reset) right after more than 64 KB of output, while the history file write is backpressured.

Expected: close() writes the queued output and returns.

Steps to reproduce

  1. Run a command that prints more than 64 KB at once in a terminal on the v1 daemon path.
  2. Close the terminal (or clear its scrollback) right away.
  3. See the app stop responding; the main process is at about 100% CPU.

Logs, screenshots, or recordings

Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.

Implementation notes

Files

  • apps/desktop/src/main/lib/terminal-history.ts close() and flushPendingWrites(): close() sets closed = true, then loops while pendingWrites is not empty and calls flushPendingWrites(). That returns at once because closed is set, so after a drain the loop never awaits and never shrinks the queue.

Approach

Remove this.closed || from the flushPendingWrites() guard. write() keeps its own closed guard.

Related

Possibly related to #9136 (frequent hangs) if that user is on the v1 terminal path. Not confirmed.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    Triage: confirmed by reading the code, not by running it

    Summary. The report says HistoryWriter.close() can spin forever on the Electron main thread. The conditions are: a terminal has a burst of output, the history file stream is backpressured, and the terminal then closes (exit, kill, clear scrollback, or daemon reset). The code supports this. Once close() sets closed = true, the helper that is meant to empty the queue returns at once without doing anything. After the first drain, the loop has nothing left to await, so it runs synchronously with a queue that never gets smaller. That pins the main process at about 100% CPU and freezes the app.

    Affected code

    • apps/desktop/src/main/lib/terminal-history.ts:337 — close() sets this.closed = true before it tries to empty the queue.
    • apps/desktop/src/main/lib/terminal-history.ts:340-365 — the loop runs while pendingWrites.length > 0 and calls flushPendingWrites(). It only awaits when isBackpressured is true.
    • apps/desktop/src/main/lib/terminal-history.ts:281-284 — flushPendingWrites() returns early on this.closed, so inside close() it never shifts anything off pendingWrites.
    • apps/desktop/src/main/lib/terminal-history.ts:211-214 — the permanent drain listener sets isBackpressured = false. It also calls flushPendingWrites(), which does nothing because closed is set.

    How it happens:

    1. A burst fills the stream buffer: stream.write() returns false and isBackpressured = true (:272-275). Later writes queue in pendingWrites (:253-267).
    2. close() runs and sets closed = true. On the first iteration, flushPendingWrites() does nothing and isBackpressured is still true, so it awaits drain or the 1 s timeout (:350-357).
    3. drain fires. This is the normal case, because the OS finishes the buffered write well inside DRAIN_TIMEOUT_MS = 1000 (:23). isBackpressured becomes false (:212 and :363).
    4. On the next iteration, pendingWrites.length > 0 is still true, flushPendingWrites() does nothing again, and isBackpressured is false, so nothing is awaited. This is a synchronous infinite loop. No timer or event can run to break it.

    The timeout only saves the app when the disk is slow enough to miss drain by 1 s. On a healthy disk the hang is the more likely result.

    Callers that reach this

    • Process exit / kill: daemon/daemon-manager.ts:169 and :677 → history-manager.ts:132-142 (closeHistoryWriter, fire-and-forget; the loop still blocks the main thread).
    • Clear scrollback: daemon/daemon-manager.ts:719, and the in-stream clear-sequence path at daemon/history-manager.ts:103.
    • Daemon reset: daemon/history-manager.ts:168-178 (resetAll).

    Note: pendingWrites only fills after a write has returned false, so the burst has to be larger than the stream's highWaterMark. The issue's "more than 64 KB" fits Node's newer stream default. I did not check the exact threshold.

    Reproduction (sketch of a bun:test)

    There is no test file next to terminal-history.ts today, so add terminal-history.test.ts:

    1. Point the history dir at a temp directory and await writer.init().
    2. Call writer.write("x".repeat(128 * 1024)) so the first write returns false, then a few more write() calls so pendingWrites is not empty.
    3. Without waiting, await writer.close().

    On current main this test should never finish: the loop is synchronous, so even bun's 5 s timeout cannot fire, and you have to kill the runner. After a fix it should finish quickly, the whole payload should be in the scrollback file, and meta.json should have endedAt. Per AGENTS.md, keep the payload small and do not rely on real disk slowness.

    Fix direction

    The approach in the issue looks correct: remove this.closed || from the guard in flushPendingWrites() (:282).

    • write() keeps its own closed guard (:230), so no new data is accepted after close.
    • After close() returns, pendingWrites is [] and stream is null (:366-377). A late drain event then calls a flush that has nothing to do and returns on !this.stream.
    • deleteHistory() also sets stream = null and clears the queue (:432-435), so it stays a no-op there too.

    A second safeguard worth adding: make the close() loop exit if an iteration did not shrink pendingWrites and did not await. A later change to the guard then cannot bring back a synchronous spin.

    Related: #9136 (frequent hangs) could be this bug if that user is on the v1 daemon terminal path. Nothing here confirms that; a main-process CPU profile from that user would settle it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions