Skip to content

Terminal (v1): last output and the real exit code are lost when output is paused at exit #9466

Open

Description

@Bruno-rose

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

  1. On the v1 terminal path, run a command that prints a lot and then exits with code 3.
  2. Keep the client slow so stdout is paused at exit.
  3. 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.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    Summary

    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

    1. Output fills the client socket or the emulator queue past EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES, and updateSubprocessStdoutFlow pauses subprocess.stdout.
    2. 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.
    3. 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.
    4. 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 FakeStdout pause() and resume() methods that buffer emitted chunks while paused.

    1. Spawn a session with createTestSession, then call sendReady and sendSpawned.
    2. Attach a fake client socket whose write() returns false, so handleClientBackpressure pauses stdout.
    3. While stdout is paused, push sendData(proc, "tail") and sendExit(proc, 3) into the buffer.
    4. Call proc.emit("exit", 0), then emit the socket's drain, then call proc.emit("close", 0).
    5. 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.

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