Description
Where did this happen?
Desktop app (macOS)
Version
Source build, main at 19db630
What happened?
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: theexithandler,handleSubprocessExit, andresetProcessState.
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.
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.