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
Terminal (v1): last output and the real exit code are lost when output is paused at exit #9466
The v1 terminal Session handles the child's exit event, not close. If stdout is paused at that moment (client backpressure or emulator backlog), resetProcessState drops the decoder and never resumes the stream. The last output and the Exit frame with the real code are lost, and the exit code reads 0.
Expected: all output written before exit is shown, with the real exit code.
Steps to reproduce
On the v1 terminal path, run a command that prints a lot and then exits with code 3.
Keep the client slow so stdout is paused at exit.
See the last lines missing and exit code 0.
Logs, screenshots, or recordings
Found by model checking every order of events, then reproduced as a failing test against the real code. Not fixed yet.
Implementation notes
Files
apps/desktop/src/main/terminal-host/session.ts: the exit handler, handleSubprocessExit, and resetProcessState.
Approach
Resume stdout on exit and handle the end on close. This needs a flag so updateSubprocessStdoutFlow does not pause stdout again while it drains, and a guard on stdin writes between exit and close (stdin has no error listener, so a write could raise EPIPE). Low priority because v1 is being retired.
The report looks plausible from the code. In the v1 terminal path, Session reads framed PTY output from the pty-subprocess child's stdout. It ends the session on the child's exit event, not close. Node can emit exit while stdout still holds unread data. That gap is wider when the session has paused stdout because a client socket or the emulator queue is backed up. At that moment resetProcessState() sets subprocess and subprocessDecoder to null. Nothing resumes stdout after that, and any bytes that do arrive go to a null decoder. So the last Data frames and the PTY Exit frame are lost. The exit code the user sees comes from the wrapper process itself, which always calls process.exit(0), so it is not the command's exit code.
Affected code
apps/desktop/src/main/terminal-host/session.ts:310-315: this.subprocess.on("exit", …) calls handleSubprocessExit(code ?? -1). There is no close listener.
apps/desktop/src/main/terminal-host/session.ts:294-306: the stdout data handler uses this.subprocessDecoder?.push(chunk) ?? [], so it silently drops data once the decoder is null.
apps/desktop/src/main/terminal-host/session.ts:481-502: handleSubprocessExit sets exitCode from the wrapper's code if no Exit frame has been read yet, broadcasts exit, calls onSessionExit, then calls resetProcessState().
apps/desktop/src/main/terminal-host/session.ts:1069-1097: resetProcessState nulls subprocess and subprocessDecoder and sets subprocessStdoutPaused = false without calling stdout.resume().
apps/desktop/src/main/terminal-host/session.ts:1213-1231: updateSubprocessStdoutFlow returns early when this.subprocess is null, so a later drain or emulator-backlog clear can never resume the stream.
apps/desktop/src/main/terminal-host/session.ts:431-450: the Exit frame handler is the only place that records the real PTY exit code and signal.
apps/desktop/src/main/terminal-host/pty-subprocess.ts:339-351: on PTY exit, the wrapper flushes output, sends the Exit frame, then calls process.exit(0) after 100 ms. The wrapper's own exit code is always 0 on this path.
Suspected root cause
Output fills the client socket or the emulator queue past EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES, and updateSubprocessStdoutFlow pauses subprocess.stdout.
The command exits with code 3. The wrapper writes the last Data frames and Exit(3) into the pipe, then exits with code 0 after 100 ms.
The parent gets exit with code 0 while those bytes are still unread. handleSubprocessExit(0) sets exitCode = 0, broadcasts exit {exitCode: 0}, and resets state.
The paused stream is never resumed. If it later drains, the decoder is null. The tail output and Exit(3) are dropped. exitCode is not null any more, so getSnapshot and isAlive also report 0.
A second, smaller issue is that subprocess.stdin has no error listener (session.ts:515-522 only listens for drain). If stdout is drained after exit, as the proposed fix does, a write that lands between exit and close could raise an EPIPE.
Reproduction (bun:test sketch)
session-shell-ready.test.ts already has FakeChildProcess and frame helpers. To make the fake stdout pausable, give FakeStdoutpause() and resume() methods that buffer emitted chunks while paused.
Spawn a session with createTestSession, then call sendReady and sendSpawned.
Attach a fake client socket whose write() returns false, so handleClientBackpressure pauses stdout.
While stdout is paused, push sendData(proc, "tail") and sendExit(proc, 3) into the buffer.
Call proc.emit("exit", 0), then emit the socket's drain, then call proc.emit("close", 0).
Expect the client to receive "tail" and exit {exitCode: 3}. Today it receives exit {exitCode: 0} and no tail.
Suggested fix direction (matches the issue's notes)
On exit, record that the wrapper has exited and resume stdout. Add a flag such as subprocessExited that makes updateSubprocessStdoutFlow stop pausing, so the pipe drains fully.
Move the end-of-session work (handleSubprocessExit and resetProcessState) to close, after all stdio has ended. Keep -1 as the fallback only when no Exit frame was read.
Between exit and close, block new stdin writes (for example, return early in flushSubprocessStdinQueue when subprocessExited is set). Add a no-op error listener on subprocess.stdin so EPIPE cannot throw.
Keep the error path (session.ts:317-323) working: if spawn fails, close may still fire, so the end handler must be safe to call more than once.
Priority looks low because v1 is being retired, but the fix is local to session.ts.
Activity
github-actions commentedon Nov 7, 2026
Summary
The report looks plausible from the code. In the v1 terminal path,
Sessionreads framed PTY output from thepty-subprocesschild's stdout. It ends the session on the child'sexitevent, notclose. Node can emitexitwhile stdout still holds unread data. That gap is wider when the session has paused stdout because a client socket or the emulator queue is backed up. At that momentresetProcessState()setssubprocessandsubprocessDecoderto null. Nothing resumes stdout after that, and any bytes that do arrive go to a null decoder. So the last Data frames and the PTYExitframe are lost. The exit code the user sees comes from the wrapper process itself, which always callsprocess.exit(0), so it is not the command's exit code.Affected code
apps/desktop/src/main/terminal-host/session.ts:310-315:this.subprocess.on("exit", …)callshandleSubprocessExit(code ?? -1). There is nocloselistener.apps/desktop/src/main/terminal-host/session.ts:294-306: the stdoutdatahandler usesthis.subprocessDecoder?.push(chunk) ?? [], so it silently drops data once the decoder is null.apps/desktop/src/main/terminal-host/session.ts:481-502:handleSubprocessExitsetsexitCodefrom the wrapper's code if noExitframe has been read yet, broadcastsexit, callsonSessionExit, then callsresetProcessState().apps/desktop/src/main/terminal-host/session.ts:1069-1097:resetProcessStatenullssubprocessandsubprocessDecoderand setssubprocessStdoutPaused = falsewithout callingstdout.resume().apps/desktop/src/main/terminal-host/session.ts:1213-1231:updateSubprocessStdoutFlowreturns early whenthis.subprocessis null, so a later drain or emulator-backlog clear can never resume the stream.apps/desktop/src/main/terminal-host/session.ts:431-450: theExitframe handler is the only place that records the real PTY exit code and signal.apps/desktop/src/main/terminal-host/pty-subprocess.ts:339-351: on PTY exit, the wrapper flushes output, sends theExitframe, then callsprocess.exit(0)after 100 ms. The wrapper's own exit code is always 0 on this path.Suspected root cause
EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES, andupdateSubprocessStdoutFlowpausessubprocess.stdout.Exit(3)into the pipe, then exits with code 0 after 100 ms.exitwith code 0 while those bytes are still unread.handleSubprocessExit(0)setsexitCode = 0, broadcastsexit {exitCode: 0}, and resets state.Exit(3)are dropped.exitCodeis not null any more, sogetSnapshotandisAlivealso report 0.A second, smaller issue is that
subprocess.stdinhas noerrorlistener (session.ts:515-522only listens fordrain). If stdout is drained afterexit, as the proposed fix does, a write that lands betweenexitandclosecould raise an EPIPE.Reproduction (bun:test sketch)
session-shell-ready.test.tsalready hasFakeChildProcessand frame helpers. To make the fake stdout pausable, giveFakeStdoutpause()andresume()methods that buffer emitted chunks while paused.createTestSession, then callsendReadyandsendSpawned.write()returnsfalse, sohandleClientBackpressurepauses stdout.sendData(proc, "tail")andsendExit(proc, 3)into the buffer.proc.emit("exit", 0), then emit the socket'sdrain, then callproc.emit("close", 0)."tail"andexit {exitCode: 3}. Today it receivesexit {exitCode: 0}and no tail.Suggested fix direction (matches the issue's notes)
exit, record that the wrapper has exited and resume stdout. Add a flag such assubprocessExitedthat makesupdateSubprocessStdoutFlowstop pausing, so the pipe drains fully.handleSubprocessExitandresetProcessState) toclose, after all stdio has ended. Keep-1as the fallback only when noExitframe was read.exitandclose, block new stdin writes (for example, return early influshSubprocessStdinQueuewhensubprocessExitedis set). Add a no-operrorlistener onsubprocess.stdinso EPIPE cannot throw.errorpath (session.ts:317-323) working: if spawn fails,closemay still fire, so the end handler must be safe to call more than once.Priority looks low because v1 is being retired, but the fix is local to
session.ts.