Skip to content

pty-daemon: resize after close reaches a disposed PTY #9453

Open

Description

@Bruno-rose

Where did this happen?

Desktop app (macOS)

Version

Source build, main at 19db630

What happened?

After a terminal is closed, the session stays in the store until the process exits. In that window handleResize still calls pty.resize() on a PTY whose master fd is already closed. A newly opened PTY can reuse that fd number and get resized instead. handleInput already refuses an exited session.

Expected: resize returns EEXITED, the same as input.

Steps to reproduce

  1. Close a terminal whose process takes a moment to exit.
  2. Resize the pane before the process exits.
  3. The resize goes to the closed fd.

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

  • packages/pty-daemon/src/handlers/handlers.ts handleResize: no session.exited check.

Approach

Add the same EEXITED guard that handleInput has.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    Triage summary

    The code supports this report. When the pty-daemon gets a close message, it disposes the PTY and marks the session exited = true. The session stays in the SessionStore until the process's exit callback removes it. During that window, handleInput rejects the session with EEXITED, but handleResize has no such check. It still calls session.pty.resize(), which issues the window-size change against a master fd that dispose() has already closed. If a new PTY opened in that window got the same fd number, the resize lands on the wrong terminal. Shells that trap or ignore SIGHUP for a while make the window wider. Both PTY types are affected.

    Affected code

    • packages/pty-daemon/src/handlers/handlers.ts:119-131: handleResize checks only that the session exists (:124), then calls session.pty.resize(...) (:126). It has no session.exited check.
    • packages/pty-daemon/src/handlers/handlers.ts:104-105: the guard handleInput already has, which returns EEXITED.
    • packages/pty-daemon/src/handlers/handlers.ts:143-158: handleClose calls kill() and then dispose(), and sets session.exited = true, but leaves the session in the store.
    • packages/pty-daemon/src/Server/Server.ts:651: the session is only store.deleted from the exit callback, so it can stay in the store for some time after close.
    • packages/pty-daemon/src/Pty/Pty.ts:283-296 and :319-323: NodePtyAdapter.dispose() calls term.destroy(), which closes the master fd. resize() has no disposed/exited check (unlike pause/resume at :343-351) and calls term.resize() straight away. I couldn't check whether node-pty itself refuses a resize after destroy(), because node_modules isn't installed in this checkout. The adapter doesn't rely on that.
    • packages/pty-daemon/src/Pty/Pty.ts:574-591: AdoptedPty.resize() runs stty with stdio: [this.fd, ...] and also has no disposed/exitFired check. After dispose() → reader.destroy() (:545), this.fd is closed and can be reused, so a resize could hand some other descriptor to stty. This is the same fd-reuse problem, in a second place.

    Suspected root cause

    The exited check from handleInput was never added to handleResize. Neither PTY adapter protects its own resize() after disposal, so nothing below the handler catches it either.

    Reproduction (bun:test sketch, in handlers.test.ts)

    test("resize after close returns EEXITED and does not touch the pty", () => {
      const ctx = makeCtx();
      handleOpen(ctx, { type: "open", id: "s0", meta: { shell: "/bin/sh", argv: [], cols: 80, rows: 24 } });
      handleClose(ctx, { type: "close", id: "s0" });
      const reply = handleResize(ctx, { type: "resize", id: "s0", cols: 100, rows: 30 });
      expect(reply).toMatchObject({ type: "error", code: "EEXITED" });
      expect(states[0]?.cols).not.toBe(100);
    });

    On current main, I expect this test to fail: handleResize returns undefined and the fake PTY records the new size. I haven't run it. Manual check: open a terminal running a shell that ignores SIGHUP for a moment (for example, trap '' HUP; sleep 5), close the pane, and resize before the process exits.

    Suggested fix

    1. In handleResize, add the same check as handleInput, right after the ENOENT check:
      if (session.exited) return errorFor(msg.id, `session exited: ${msg.id}`, "EEXITED");
    2. As extra protection, make both adapters' resize() do nothing (or throw) once disposed/exited is set. NodePtyAdapter already does this in pause/resume. That closes the fd-reuse risk for any caller, not just this handler. It matters most for AdoptedPty, where this.fd goes directly to a child process.

    The regression test above covers the handler. A small adapter-level test that calls dispose() and then resize() would cover the second change.

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