Open
Description
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
- 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()andflushPendingWrites():close()setsclosed = true, then loops whilependingWritesis not empty and callsflushPendingWrites(). That returns at once becauseclosedis 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.
Metadata
Metadata
Assignees
Labels
No labels
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.