Skip to content

Windows crash on Node.js v22 – "Cannot resize a pty that has already exited" #827

Description

Environment:

  • OS: Windows 11
  • Node.js version: v22.12.0
  • Package: node-pty (via @lydell/node-pty, dependency of @google/gemini-cli)

Description:
When running @google/gemini-cli on Windows 11 with Node.js v22, the process crashes with the following error:

Error: Cannot resize a pty that has already exited
at WindowsPtyAgent.resize (node_modules/@lydell/node-pty/windowsPtyAgent.js:93:19)
at WindowsTerminal. (node_modules/@lydell/node-pty/windowsTerminal.js:131:26)
at Object.run (node_modules/@lydell/node-pty/windowsTerminal.js:167:50)
...

Steps to reproduce:

  1. Install Node.js v22.12.0 on Windows 11.
  2. Install @google/gemini-cli globally (npm install -g @google/gemini-cli).
  3. Run a command that triggers an interactive PTY session (e.g. rebasing workflow).
  4. Observe crash when the PTY process exits and resize is attempted.

Expected behavior:
Resize calls after PTY exit should fail gracefully or be ignored, not crash the entire process.

Actual behavior:
Process crashes with uncaught error "Cannot resize a pty that has already exited".

Notes:

  • This issue does not occur under Node.js v20 LTS.
  • It appears to be a runtime compatibility problem between node-pty and Node.js v22 ABI/stream changes.
  • Rebuilding node-pty from source (gyp_rebuild=true) does not resolve the runtime crash.

Request:
Please add compatibility support for Node.js v22 on Windows, or handle PTY resize calls more gracefully after exit.

Activity

  1. Tyriar commented on Dec 9, 2025

    @Tyriar
    Contributor

    That error comes from here:

    if (this._exitCode !== undefined) {
    throw new Error('Cannot resize a pty that has already exited');
    }

    It's just a thrown node exception, not a process crash? Your stack trace is also a node error trace?

  2. SkybuckFlying commented on Dec 20, 2025

    @SkybuckFlying
    Author

    Thank you for your feedback, just to be thorough I shall post a possible solution for this problem:

    Here is the refined technical report, formatted for a GitHub Issue or Pull Request. It includes the stack trace as evidence and clearly distinguishes between a library logic error and a native crash.


    Issue: Process crash (Exit Code 1) due to unhandled 'already exited' error in resize()

    Summary

    The application experiences a fatal crash when a resize event occurs simultaneously with or immediately after the PTY process terminates. This is not a native memory crash (segfault), but a synchronous JavaScript exception thrown by node-pty that goes uncaught, causing the Node.js runtime to perform an emergency shutdown.

    Evidence: Stack Trace

    The following trace confirms that the crash is triggered by the WindowsPtyAgent guard clause during a socket-driven event:

    G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsPtyAgent.js:93
                throw new Error('Cannot resize a pty that has already exited');
                ^
    
    Error: Cannot resize a pty that has already exited
        at WindowsPtyAgent.resize (G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsPtyAgent.js:93:19)
        at WindowsTerminal.<anonymous> (G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsTerminal.js:131:26)
        at Object.run (G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsTerminal.js:167:50)
        at G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsTerminal.js:73:32
        at Array.forEach (<anonymous>)
        at Socket.<anonymous> (G:\Tools\npm-global\node_modules\@google\gemini-cli\node_modules\@lydell\node-pty\windowsTerminal.js:68:42)
        at Socket.emit (node:events:536:35)
        at addChunk (node:internal/streams/readable:561:12)
        at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
        at Readable.push (node:internal/streams/readable:392:5)
    
    Node.js v22.12.0
    
    

    Technical Root Cause

    The crash is located in node-pty/src/windowsPtyAgent.ts (or the equivalent .js in the @lydell/node-pty fork):

    // Lines 126-128 in node-pty source
    if (this._exitCode !== undefined) { 
      throw new Error('Cannot resize a pty that has already exited'); 
    } 
    1. Race Condition: The PTY sets _exitCode when the shell process terminates.
    2. Delayed Event: A pending resize event (triggered by the terminal UI or a socket buffer flush) calls .resize() on the PTY instance.
    3. Fatal Exception: node-pty throws an explicit Error. Because the calling logic in windowsTerminal.js does not wrap this in a try/catch, the exception bubbles up to the Node.js global scope.
    4. Runtime Shutdown: Node.js v22.x treats uncaught synchronous exceptions as fatal, terminating the process to prevent an inconsistent state.

    Proposed Solution

    The PTY manager should defensively handle the possibility that a resize request may arrive after the process has died.

    Recommended Fix:
    Wrap the agent resize call in windowsTerminal.js with a try/catch block to safely ignore this specific lifecycle error:

    // Suggested patch for windowsTerminal.js
    try {
        this._agent.resize(cols, rows);
    } catch (e) {
        if (e instanceof Error && e.message === 'Cannot resize a pty that has already exited') {
            // Log as warning or ignore; the process is already dead so resize is moot.
            return;
        }
        throw e; // Re-throw any other unexpected errors
    }

    Would you like me to help you draft the specific "Steps to Reproduce" section for the bug report?

    Background information:

    To understand what a PTY (Pseudo-Terminal) does, you have to look at how software communicates with a computer.

    Normally, a program (like ls or grep) just takes input and spits out output. But some programs—like interactive shells (bash, powershell), text editors (vim, nano), or REPLs (Node, Python)—expect to be talking to a real human sitting at a physical terminal. They need to handle things like window resizing, keyboard shortcuts (Ctrl+C), and cursor movement.

    A PTY is the "middleman" that tricks these programs into thinking they are attached to a real terminal.


    The Two Sides of a PTY

    A PTY is actually a pair of devices that act like a bidirectional pipe:

    1. The Master (The "Controller"):
      This is the side held by the Terminal Emulator (like VS Code's terminal, CMD, or your Gemini CLI). It sends user input (keystrokes) and receives the program’s output to display on the screen.
    2. The Slave (The "Fake Terminal"):
      This is the side held by the Shell or Process (like bash.exe). To the shell, the Slave side looks and behaves exactly like a hardware serial port or a physical teletype machine.

    Key Responsibilities of a PTY

    1. Line Discipline (The "Cooked" Mode)

    When you type hello and hit backspace to fix a typo, the PTY handles that. It doesn't send the "h-e-l-l-o-backspace" characters to the program immediately. It "cooks" the input, allowing you to edit the line, and only sends the final string to the program once you hit Enter.

    2. Control Signal Handling

    When you press Ctrl+C, you aren't sending a "C" character. You are sending a signal. The PTY recognizes this special key combination and sends a SIGINT (Interrupt) to the running process to stop it.

    3. Terminal Emulation & Layout

    The PTY keeps track of the column and row count.

    • When you resize your window, the "Master" tells the PTY the new dimensions.
    • The PTY then sends a signal (SIGWINCH) to the "Slave" process.
    • The process (like vim) receives that signal and redraws its UI to fit the new size.

    Note: This is exactly where your crash was happening. The "Master" (Gemini CLI) tried to tell the PTY the window dimensions changed, but the "Slave" (the shell) had already died and left the building.

    4. Echoing

    When you type a character, it doesn't automatically show up on your screen. The PTY "echoes" the character back to the Master so the terminal emulator knows to draw a "A" when you press the A key.


    Why do we need node-pty?

    Node.js normally uses child_process.spawn, which uses standard "Pipes" (stdin/stdout). Pipes are "dumb"—they don't understand window resizing or Ctrl+C.

    If you tried to run vim or an interactive python shell through a standard Node.js pipe, it would likely fail or look broken because those programs would realize they aren't attached to a "real" terminal. node-pty provides the native bindings to create a "real" PTY handle, allowing Node.js apps to host fully functional interactive terminals.

  3. Tyriar commented on Dec 20, 2025

    @Tyriar
    Contributor

    We would be throwing regardless which I think is good behavior. You should try/catch on your end so you can handle the case when resizing something that cannot be resized.

  4. SkybuckFlying commented on Dec 29, 2025

    @SkybuckFlying
    Author

    We would be throwing regardless which I think is good behavior. You should try/catch on your end so you can handle the case when resizing something that cannot be resized.

    The try/catch approach was implemented but the program still crashes, the AI seems to understand why, see below for full explanation, there is more going on then it seems at first glance, it's quite hard/complex to understand, it's some kind of interaction between the c++ world, the operating systems exceptions, the binding, and the fact that java script engine/node.js does not like handling os exceptions and simply crashes because they cannot garantue proper function.

    Final bug fix by me and the AI going to do a pull request soon, this bug fix should be a bit more reliable if error message changes and also makes the race condition window very small for the isPtyActive and such, the try catch blocks are finally the end cure:
    
      /**
       * Resizes the pseudo-terminal (PTY) of a running process.
       *
       * @param pid The process ID of the target PTY.
       * @param cols The new number of columns.
       * @param rows The new number of rows.
       */
      static resizePty(pid: number, cols: number, rows: number): void {
        // IMPORTANT CHANGE: Fetch the PTY reference once and guard on it immediately.
        // This eliminates a subtle race condition present in the original code.
        // Original: Checked isPtyActive(pid) first, then later fetched activePtys.get(pid).
        // Between those two calls, the entry could be removed from the Map (e.g., by an async exit handler),
        // leading to activePty being undefined even after the PID check passed.
        // New: Single lookup + combined guard ensures atomicity and consistency.
        const activePty = this.activePtys.get(pid);
        if (!activePty || !this.isPtyActive(pid)) {
          return;
        }
    
        try {
          activePty.ptyProcess.resize(cols, rows);
          activePty.headlessTerminal.resize(cols, rows);
        } catch (e) {
          // Safely ignore the known race-condition error
          // Ignore errors if the pty has already exited, which can happen
          // due to a race condition between the exit event and this call.
          if (
            e instanceof Error &&
            ((e.message.toLowerCase().includes('pty') &&
              e.message.toLowerCase().includes('exited')) || // Windows (robust against wording changes)
              ('code' in e && (e as { code?: string }).code === 'ESRCH')) // Unix
          ) {
            return; // Safe to ignore - process already exited - resize is irrelevant
          }
          // For other unexpected errors, re-throw
          throw e;
        }
      }
    
      /**
       * Scrolls the pseudo-terminal (PTY) of a running process.
       *
       * @param pid The process ID of the target PTY.
       * @param lines The number of lines to scroll.
       */
      static scrollPty(pid: number, lines: number): void {
        const activePty = this.activePtys.get(pid);
        if (!activePty || !this.isPtyActive(pid)) {
          return;
        }
        try {
          activePty.headlessTerminal.scrollLines(lines);
          if (activePty.headlessTerminal.buffer.active.viewportY < 0) {
            activePty.headlessTerminal.scrollToTop();
          }
        } catch (e) {
          // Ignore errors if the pty has already exited, which can happen
          // due to a race condition between the exit event and this call.
          // Ignore known exit-related errors (similar to resize)
          if (
            e instanceof Error &&
            ((e.message.toLowerCase().includes('pty') &&
              e.message.toLowerCase().includes('exited')) || // Windows (robust against wording changes)
              ('code' in e && (e as { code?: string }).code === 'ESRCH')) // Unix
          ) {
            return;
          }
          throw e;
        }
      }
    
    
    $ shell
    ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
    │ ⊶  Shell Command shell                                                                                                                                                    │
    │                                                                                                                                                                           │
    ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
    
    ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
    │ A new version of Gemini CLI is available! 0.21.0-nightly.20251220.41a1a3eed → 0.22.2                                                                                      │
    │ Running from a local git clone. Please update with "git pull".                                                                                                            │
    ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
    
    ⠹ Channeling the Force... (esc to cancel, 0s)
    
     Using: 2 GEMINI.md files                                                                                                                 shell mode enabled (esc to disable)
    ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
    │ !   Type your shell command                                                                                                                                               │
    ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
     M:\GeminiCLI\Branch\Develop\Typescript (fix/windows-pty-resize-crash-14557*)                         no sandbox (see /docs)                          Auto (Gemini 3) /model
    M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsPtyAgent.js:93
                throw new Error('Cannot resize a pty that has already exited');
                ^
    
    Error: Cannot resize a pty that has already exited
        at WindowsPtyAgent.resize (M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsPtyAgent.js:93:19)
        at WindowsTerminal.<anonymous> (M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsTerminal.js:131:26)
        at Object.run (M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsTerminal.js:167:50)
        at M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsTerminal.js:73:32
        at Array.forEach (<anonymous>)
        at Socket.<anonymous> (M:\GeminiCLI\Branch\Develop\Typescript\node_modules\@lydell\node-pty\windowsTerminal.js:68:42)
        at Socket.emit (node:events:536:35)
        at addChunk (node:internal/streams/readable:561:12)
        at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
        at Readable.push (node:internal/streams/readable:392:5)
    
    Node.js v22.12.0
    PS M:\GeminiCLI\Branch\Develop\Typescript>
    
    
    

    Unfortunately a deeper bug has been found:

    This is a different crash, coming from node‑pty itself, before your code even gets a chance to intercept it.

    Let me walk you through what just happened with absolute clarity.


    🧩 1. This crash is NOT coming from your patched code

    Your fix lives in:

    packages/core/src/services/shellExecutionService.ts
    

    But the crash stack trace is coming from:

    node_modules/@lydell/node-pty/windowsPtyAgent.js
    node_modules/@lydell/node-pty/windowsTerminal.js
    

    This means:

    ❌ The resize event is reaching node‑pty before your wrapper code can catch it.

    ❌ node‑pty is throwing synchronously inside its internal resize handler.

    ❌ Your try/catch cannot intercept this because it’s happening inside the native binding callback.

    This is a lower‑level crash than the one you fixed.


    🧩 2. The exact error

    Error: Cannot resize a pty that has already exited
    

    This is thrown by node‑pty when:

    • the PTY process has already died
    • but the terminal still receives a resize event
    • and node‑pty tries to forward the resize to a dead handle

    This is a known Windows‑specific race condition.


    🧩 3. Why your fix didn’t catch this

    Your fix guards the Gemini‑CLI layer, not the node‑pty native layer.

    But this crash happens inside the native callback, before your code runs.

    Your fix prevents your code from calling resize on a dead PTY.
    But node‑pty itself is also listening for resize events and doing its own resize call.

    That’s the one that exploded.


    🧩 4. What this means

    You’ve uncovered a second bug, deeper than the one you fixed.

    The sequence is:

    1. Gemini CLI opens a PTY
    2. You resize the terminal
    3. The PTY exits (maybe instantly, maybe due to a race)
    4. Windows Terminal sends a resize event
    5. node‑pty receives the resize
    6. node‑pty tries to resize a dead PTY
    7. node‑pty throws
    8. The whole process crashes

    Your fix only prevents Gemini‑CLI from calling resize after exit.
    It does not prevent node‑pty from doing the same.


    🧩 5. The real root cause

    node‑pty’s Windows backend does not guard against:

    • resize events arriving after exit
    • resize events arriving during teardown
    • resize events arriving before initialization

    This is a long‑standing issue in node‑pty.


    🧩 6. What we need to do next

    To fully fix this, we need to:

    ✔ Patch node‑pty’s Windows backend

    Specifically in:

    windowsPtyAgent.js
    windowsTerminal.js
    

    Add guards like:

    if (this._exited) return;

    before calling:

    this._agent.resize(cols, rows);

    ✔ OR intercept resize events earlier

    Inside Gemini‑CLI, before they reach node‑pty.


    Your fix worked — but you just uncovered a deeper, upstream bug in node‑pty itself.

  5. SkybuckFlying commented on Dec 29, 2025

    @SkybuckFlying
    Author

    Explanation about (a)synchronous JavaScript exception in the context of c/c++ addons for node.js:

    In JavaScript, a synchronous exception is an error that occurs during the immediate execution of a block of code.1 Because JavaScript is single-threaded, when a synchronous exception is "thrown," the engine stops what it is doing and looks for an error handler (a try...catch block).

    If no handler is found, the script crashes or stops executing that specific task.


    How It Works: The Call Stack

    When you run synchronous code, each function call is added to the Call Stack.2 If an error happens, JavaScript starts "bubbling" that error up the stack. It checks the current function for a catch block; if it doesn't find one, it pops that function off and checks the caller, and so on.

    Common Examples

    Synchronous exceptions usually stem from syntax errors, logic flaws, or trying to access something that doesn't exist.

    • ReferenceError: Using a variable that hasn't been declared.

    • TypeError: Performing an operation on the wrong data type (e.g., calling a method on null).3

    • RangeError: Passing a number to a function that is outside the allowed range.

    JavaScript
    function checkInventory() {
      // This will throw a synchronous TypeError because 'item' is undefined
      let item;
      console.log(item.name); 
    }
    

    try {
    checkInventory();
    } catch (error) {
    console.error("Caught a sync error:", error.message);
    }


    Synchronous vs. Asynchronous Exceptions

    The distinction is vital because standard try...catch blocks cannot catch asynchronous exceptions.

    Feature Synchronous Exception Asynchronous Exception
    Detection Happens immediately during execution. Happens later (e.g., in a setTimeout or Promise).
    Handling Caught easily with try...catch. Requires .catch() or await within a try block.
    Stack Trace Provides a clear path of how you got there. Can sometimes be "lost" or disconnected from the original caller.

    The Catch-22 of Async:

    If you wrap a setTimeout in a try...catch, the try block finishes executing before the error inside the timeout even happens. This is why synchronous error handling logic is insufficient for modern, API-heavy web apps.


    Key Takeaway

    A synchronous exception is a "stop-the-world" event. It follows the linear path of your code. To handle it effectively, you should wrap risky logic (like parsing JSON or accessing deep object properties) in try...catch blocks to ensure your application can recover gracefully.4

    Would you like to see how to properly handle exceptions within Asynchronous code using async/await?

  6. SkybuckFlying commented on Dec 29, 2025

    @SkybuckFlying
    Author

    For me as a none-javascript, none-typescript, none node.js programmer this is hard to understand, but I can understand some of it.

    To me it seems like both are crashing at the same time. Both are trying to re-size. One is handled, the other one is not and still produces a second exception crashing the main program.

    Perhaps you can figure it out... this is some very complex interaction going on between the addon, api, binding, etc, plus operating system events, etc, very difficult.

  7. SkybuckFlying commented on Dec 29, 2025

    @SkybuckFlying
    Author

    One last note, according to the AI, all other PTY solutions do not throw on this kind of re-size event when the PTY is already exited... however I want to give you the benefit of the doubt, I kinda like that you are trying to inform the user that it might be doing something wrong, however this is creating a very serious problem, and according to the AI, the user cannot do anything about it.

    As also demonstrated by this try/catch solution which didn't actually solve the deeper problem. So in this case, it's literally impossible for the typescript/javascript developers to solve this throw ? and thus might as well leave it out.

    I believe looking at the c/c++ code that it probably also does a throw on a resize when it failed.

    So perhaps it's just an example of unnecessary "throws" or at least a situation where throwing could be considered bad.

    Ultimately this is a philosophical dilemma, should the java script engine itself/node.js itself handled it, they have already decided not to because they cannot garantue virtual machine correct function if an add-on misbehaves.

    Maybe there is a different way of notifieing the user of this condition/error, or perhaps a compromise solution would be to include a little boolean which can be set, to throw or not throw for this particular situation so that this crash can be avoided.

    However I must warn you the AI also detected other potential race conditions. For this reason I have started an alternative implementation: Delphi-pty, it's on github as well on my github account.

    I will try and integrate this new AI driven solution into gemini-cli to see if it can solve this problem.

    I could have maybe simply fixed it, but disabling throw lines, only to discover later that this node-pty has other potential crashes and I really want gemini-cli to be rock solid, so I am going the route of re-implementing it, I was under the impression that AI was going to use a new API for PTYs, now I am not so sure anymore, maybe it kinda lied to me, I don't know. Soon I will find out if this delphi-pty works or not for gemini-cli... it would still be nice if a this project gets fixed as a fall back option, especially because it might be more cross platform and maybe my Delphi-pty solution might not be accepted by gemini-cli developers...

    According to the AI the Delphi-pty implementation might be faster. I am not sure if that is true. Meanwhile it also implemented a minimalistic c/c++ version for debugging purposes.

    The AI also complained that not all error return values were being checked in this node-pty code. Not sure anymore if that is really true ?!

    Here is the new implementation, mostly in Delphi and some C/C++ typescript and javascript bindings:
    https://github.com/SkybuckFlying/Delphi-Pty

    Since you have more experience with this I would be interesting if you could take a look at it, to see what your initial thoughts are ?

    Would it be better or worse ? does it use the same APIs ? Or indeed newer ? How is the return value checking ?

    Maybe this project can even learn from the AI.

    The latest and greatest AI was used: Gemini 3.0 Pro, Gemini 3.0 Flash and Claude 4.5 Sonnet.

    I found it impressive how the AI was capable of doing everything and even debugging it.

    I am not yet sure if it's 100% correct/debugged.

    One thing which is kinda odd: the cmd.exe window does not re-size on re-size command, but apperently that is normal, the multi-tab version of cmd.exe does not give that kind of control ? Again not sure. I am very new to this and just using the AI to try and make sense of it. This is definetly one of the most complex things I've look at during my life time. (Personal note, I've had a long day, so my mind is slightly gone, but I am doing my best to make some sense of it, forgive me if it's not 100% crystal clear (combination of fatigue+complexity :), I wish I could do better/understand better, cheers).

  8. SkybuckFlying commented on Dec 29, 2025

    @SkybuckFlying
    Author

    While taking to the Gemini AI from Gemini-CLI this bug can be triggered by asking the AI to do: "echo hi".

    This will cause the AI to open a pty and a cmd.exe, after cmd.exe exits a resize event happens from inside gemini cli I think and this causes an "exception" to bubble up through the stack, now I can remember... this is a c/c++ kind of exception it must be caught if not c/c++ will unwind the stack, javascript/nodejs kinda detects this bubble and ultimately crashes it.

    Anyway here is the AI explanation:

    You’re circling the right failure mode, but the mechanism is a bit different from “the OS sends a resize because the window disappears.” Let me give you the precise, technical version of what’s actually happening — because the real cause is subtler and more interesting.


    🧩 What actually happens (and why it crashes)

    ✔ 1. cmd.exe (or bash) is spawned inside a PTY — not a real window

    There is no visible terminal window, so nothing is “disappearing.”

    A PTY is just a pair of file descriptors:

    • master side (Node.js)
    • slave side (shell process)

    No GUI. No OS window.
    So the OS does not send a resize event because of a disappearing window.


    🧩 2. The resize event comes from Gemini‑CLI itself, not the OS

    Gemini‑CLI does this:

    • It spawns a PTY
    • It attaches a resize handler
    • It tries to keep the PTY width in sync with its own UI
    • It may reflow output or adjust width after the command runs

    This resize is internal, not OS‑driven.


    🧩 3. The crash happens because the resize fires after the PTY process exits

    This is the real race:

    1. cmd.exe runs echo hi
    2. It exits instantly
    3. Gemini‑CLI tries to resize the PTY (for formatting)
    4. node-pty checks if the PTY is alive
    5. It isn’t
    6. Native C++ throws:
      “Cannot resize a pty that has already exited”
    7. JavaScript also throws because the native exception bubbles up
    8. Gemini‑CLI doesn’t catch it
    9. Crash

    So you get what feels like a “dual throw,” but it’s really:

    • One throw from C++
    • Propagated into JS
    • Uncaught
    • Process terminates

    🧩 4. Why it feels like two throws

    Because:

    • The C++ layer throws synchronously
    • Node.js converts that into a JS exception
    • The JS exception is also thrown synchronously
    • The CLI prints both stack traces or error messages

    So it looks like two errors, but it’s one error traveling through two layers.


    🧠 So your hypothesis was close, but the real cause is:

    ❌ Not the OS

    ❌ Not a disappearing window

    ❌ Not a dual throw from two independent sources

    ✔ It’s a race condition between:

    • a fast‑exiting PTY child process
    • a late resize call from Gemini‑CLI
    • a synchronous native exception in node-pty
    • an uncaught JS exception in the CLI

    That’s the whole story.


    If you want, I can sketch the exact event timeline as an ASCII diagram so you can see the race visually.

    !!!

    Additionally:

    !!!

    Why try catch does not stop it from crashing:

    !!!

    Here’s the real reason your try/catch in JavaScript or TypeScript does nothing to stop the Gemini‑CLI crash — and it’s one of those deep Node.js truths that bites every engineer at least once.

    I’ll give you the clean, technical explanation without fluff.


    🧩 1. The crash happens before your JS try/catch ever runs

    The exception is thrown inside native C++ code in node-pty, using something like:

    Napi::Error::New(env, "Cannot resize a pty that has already exited").ThrowAsJavaScriptException();

    When a native module throws like this:

    • It does not unwind into your JS try/catch
    • It unwinds into the Node.js event loop
    • If nothing catches it at the native boundary, Node terminates the process

    Your try/catch is too far up the call stack to intercept it.


    🧩 2. The resize happens asynchronously — outside your try/catch block

    Even if you wrap the shell call:

    try {
      pty.resize(100, 40);
    } catch (e) {
      console.log("caught");
    }

    Gemini‑CLI doesn’t call resize() inside your code.

    It calls it:

    • in an event handler
    • in a callback
    • in a promise chain
    • in a UI reflow step
    • in a terminal update loop

    Your try/catch only protects synchronous code inside its own block.

    The resize happens later, in a different tick of the event loop.

    So your try/catch never sees it.


    🧩 3. Native exceptions thrown during async callbacks bypass JS try/catch entirely

    This is the killer detail.

    When C++ throws during an async callback:

    • Node tries to convert it to a JS exception
    • But there is no active JS frame to catch it
    • So Node treats it as a fatal exception
    • The process crashes

    This is why you see:

    • A native stack trace
    • A JS stack trace
    • Then the whole CLI dies

    🧩 4. Even if you wrap the entire tool call, it still won’t help

    Because the crash is happening in a different call stack.

    Your code:

    try {
      runShellTool();
    } catch (e) {
      console.log("caught");
    }

    Gemini‑CLI’s internal code:

    spawn pty → process exits → resize event fires → C++ throws → Node dies
    

    Your try/catch is not on the stack when the resize happens.


    🧩 5. This is why the crash feels “uncatchable”

    Because it is.

    It’s a native exception thrown during an async callback, which is one of the few things that can still kill a Node.js process instantly.


    🧠 The real fix (conceptually)

    To prevent the crash, the CLI must:

    ✔ Check if the PTY is alive before resizing

    ✔ Wrap the resize call in a try/catch inside the callback

    ✔ Or patch the native module to ignore resize-after-exit

    ✔ Or disable resize events entirely for short-lived commands

    This must be done inside Gemini‑CLI, not in your code.


    🧩 In one sentence

    Your try/catch doesn’t work because the crash happens in native C++ code during an asynchronous callback, outside the JavaScript call stack your try/catch controls.


    If you want, I can show you the exact call chain inside node-pty that makes this exception uncatchable, or sketch a patch that would make Gemini‑CLI immune to this race condition.

  9. Tyriar commented on Dec 30, 2025

    @Tyriar
    Contributor

    But the crash stack trace is coming from:

    node_modules/@lydell/node-pty/windowsPtyAgent.js
    node_modules/@lydell/node-pty/windowsTerminal.js

    That's an outdated fork of this project, have you tried using actual node-pty on npm?

  10. added
    bugIssue identified by VS Code Team member as probable bug
    help wantedIssues identified as good community contribution opportunities
    and removed on Dec 30, 2025
  11. Tyriar commented on Dec 30, 2025

    @Tyriar
    Contributor

    If you can put together a repro case and a fix it'll probably get merged.

  12. Tyriar commented on Jan 3, 2026

    @Tyriar
    Contributor

    This is on hold and waiting for an external fix, if this impacts Gemini CLI in a bad way folks who use or maintain that would ideally contribute the fix as it's not impacting us AFAIK.

  13. jackwotherspoon commented on Jan 5, 2026

    @jackwotherspoon

    Hi Daniel Imms (@Tyriar) 👋

    Gemini CLI team member here. What kind of fix is needed in the node-pty lib in your mind for this?

    We are indeed seeing this crash quite frequently with Gemini CLI users.

    Let me know, happy to try and contribute a fix.

  14. Tyriar commented on Jan 5, 2026

    @Tyriar
    Contributor

    Jack Wotherspoon (@jackwotherspoon) the analysis above looks like it's on to something, it's a lot to read though 😅. Try set up gemini CLI to point at a local clone of node-pty and then try debug with a debugger or logging to repro the crash and then figure out how to mitigate it. I imagine it'll look something like if (this.isDisposed) { return } in TS or something in pty.cc to double check a native call won't crash.

  15. euxaristia commented on Mar 13, 2026

    @euxaristia

    Hi Daniel Imms (@Tyriar) 👋

    Gemini CLI team member here. What kind of fix is needed in the node-pty lib in your mind for this?

    We are indeed seeing this crash quite frequently with Gemini CLI users.

    Let me know, happy to try and contribute a fix.

    I fixed it in my fork (euxaristia/node-pty), but I accidentally forked a repackage of this repo. So I'll be contributing it here asap.

  16. added 2 commits that reference this issue on Mar 13, 2026
    9db66e2
    aeec16d
  17. euxaristia commented on Mar 13, 2026

    @euxaristia
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

    bugIssue identified by VS Code Team member as probable bughelp wantedIssues identified as good community contribution opportunities

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions