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
{{ message }}
Repository navigation
Desktop freezes when a terminal closes right after a burst of output #9450
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
Run a command that prints more than 64 KB at once in a terminal on the v1 daemon path.
Close the terminal (or clear its scrollback) right away.
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.tsclose() 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.
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:
A burst fills the stream buffer: stream.write() returns false and isBackpressured = true (:272-275). Later writes queue in pendingWrites (:253-267).
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).
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).
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.
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:
Point the history dir at a temp directory and await writer.init().
Call writer.write("x".repeat(128 * 1024)) so the first write returns false, then a few more write() calls so pendingWrites is not empty.
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.
Activity
github-actions commentedon Nov 7, 2026
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. Onceclose()setsclosed = true, the helper that is meant to empty the queue returns at once without doing anything. After the firstdrain, the loop has nothing left toawait, 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()setsthis.closed = truebefore it tries to empty the queue.apps/desktop/src/main/lib/terminal-history.ts:340-365— the loop runs whilependingWrites.length > 0and callsflushPendingWrites(). It only awaits whenisBackpressuredis true.apps/desktop/src/main/lib/terminal-history.ts:281-284—flushPendingWrites()returns early onthis.closed, so insideclose()it never shifts anything offpendingWrites.apps/desktop/src/main/lib/terminal-history.ts:211-214— the permanentdrainlistener setsisBackpressured = false. It also callsflushPendingWrites(), which does nothing becauseclosedis set.How it happens:
stream.write()returns false andisBackpressured = true(:272-275). Later writes queue inpendingWrites(:253-267).close()runs and setsclosed = true. On the first iteration,flushPendingWrites()does nothing andisBackpressuredis still true, so it awaitsdrainor the 1 s timeout (:350-357).drainfires. This is the normal case, because the OS finishes the buffered write well insideDRAIN_TIMEOUT_MS = 1000(:23).isBackpressuredbecomes false (:212and:363).pendingWrites.length > 0is still true,flushPendingWrites()does nothing again, andisBackpressuredis 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
drainby 1 s. On a healthy disk the hang is the more likely result.Callers that reach this
daemon/daemon-manager.ts:169and:677→history-manager.ts:132-142(closeHistoryWriter, fire-and-forget; the loop still blocks the main thread).daemon/daemon-manager.ts:719, and the in-stream clear-sequence path atdaemon/history-manager.ts:103.daemon/history-manager.ts:168-178(resetAll).Note:
pendingWritesonly fills after a write has returned false, so the burst has to be larger than the stream'shighWaterMark. 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.tstoday, so addterminal-history.test.ts:await writer.init().writer.write("x".repeat(128 * 1024))so the first write returns false, then a few morewrite()calls sopendingWritesis not empty.await writer.close().On current
mainthis 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, andmeta.jsonshould haveendedAt. 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 influshPendingWrites()(:282).write()keeps its ownclosedguard (:230), so no new data is accepted after close.close()returns,pendingWritesis[]andstreamisnull(:366-377). A latedrainevent then calls a flush that has nothing to do and returns on!this.stream.deleteHistory()also setsstream = nulland 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 shrinkpendingWritesand 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.