Skip to content

fix(vscode-ide-companion): make IdeServer.stop() resolve while MCP sessions are open - #29674

Open
elberthc-byte wants to merge 4 commits into
google-gemini:mainfrom
elberthc-byte:fix/ide-server-stop-hang-28785
Open

elberthc-byte wants to merge 4 commits into
google-gemini:mainfrom
elberthc-byte:fix/ide-server-stop-hang-28785

Conversation

@elberthc-byte

@elberthc-byte elberthc-byte commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

IdeServer.stop() never resolved while a Gemini CLI session was connected to the VS Code companion. stop() awaited http.Server.close(), which only stops accepting new connections and waits for existing ones to drain — but the CLI's StreamableHTTPClientTransport holds a standalone GET /mcp SSE stream open for the life of the session, so nothing ever drained. Extension deactivate() blocked behind it, and the cleanup after the await (environment-variable collection, port file) never ran, which can leave a stale port file for getConnectionConfigFromFile to pick up on the next launch.

This PR makes stop() close the MCP sessions first and force-close remaining sockets, so it resolves promptly and always runs its cleanup. Two files, no API changes, no new eslint-disable directives.

Details

stop() (packages/vscode-ide-companion/src/ide-server.ts)

  1. Explicit shutdown lifecycle. status: 'idle' | 'stopping' | 'stopped': a concurrent stop() while stopping joins the in-flight promise and so resolves only once the listener, sockets and port file are actually gone; stop() when already stopped is a no-op (previously a second call rejected with ERR_SERVER_NOT_RUNNING). start() resets the status to idle and bumps a lifecycle generation; a shutdown that finishes after a newer start() neither marks the new server stopped, nor clears its env vars, nor unlinks its port file (the old port file path is captured synchronously), so overlapping stop/start/stop cycles are isolated.
  2. Close every transport first via Promise.allSettled(transports.map(t => t.close())). StreamableHTTPServerTransport.close() ends the SSE responses and fires onclose synchronously, which is where the keep-alive interval is cleared and the session evicted — so no timer can outlive stop(). There is no timeout race; every registered transport is closed before the server is.
  3. server.closeAllConnections() alongside server.close() so idle keep-alive sockets (not tied to any transport) cannot hold the close callback either. Requires Node ≥ 18.2; VS Code ^1.99 ships Node 20+ and @types/node 20.x already declares it.
  4. Cleanup in finally. environmentVariableCollection.clear() and fs.unlink(portFile) now run even if server.close() reports an error.

Keep-alive (missedPings >= 3 branch)

The branch logged "Closing connection and cleaning up interval" but only cleared the interval, leaving the session in this.transports. It now clears the interval and calls transport.close() (rejections caught and logged, so a transport that cannot close is neither an unhandled rejection nor retried every 60 s); onclose evicts the session. The missedPings = 0 reset on success is intentionally unchanged — "3 consecutive misses" is the sane reading of the threshold, and changing it is a policy decision outside this bug (see discussion on #28789).

Why the first test attempts passed without the fix (for reviewers)

The OpenFilesManager mock in this test file had no state, so the GET /mcp handler threw while building the initial ide/contextUpdate notification, Express 5 destroyed the SSE socket, and the hang was masked. The mock now exposes a schema-valid state, and the new helper connectMcpClient() waits for the ide/contextUpdate notification as the deterministic "SSE stream is live" signal before calling stop(). With that in place, 11 of the 12 new tests fail against main and pass with this change.

Tests added (ide-server.test.ts, describe('shutdown with active MCP sessions'))

  • stop() resolves while a session holds an open SSE stream (the IdeServer.stop() never resolves while an MCP session is open #28785 regression test)
  • closes sessions, clears keep-alive, clears env collection, removes the port file
  • closes every session with multiple clients connected
  • stop() is idempotent
  • concurrent stop() calls await the same in-flight shutdown (second caller stays pending until cleanup has run)
  • the server is stoppable again after stop() → start() (restart)
  • a late-finishing stop() does not clobber a restarted server (only its own port file is unlinked; env vars untouched; new server still stoppable)
  • a second shutdown stays joinable when the first finishes later (status is not flipped to stopped prematurely)
  • cleanup still runs when server.close() errors
  • keep-alive: 3 consecutive missed pings close the transport and evict the session
  • keep-alive: missed-ping count resets on a successful ping (behavior-preserving)
  • keep-alive: a rejected transport.close() is logged and the interval stops

Out of scope (pre-existing, found during verification)

A single McpServer is connect()ed to every transport, so the SDK's Protocol._transport is overwritten on each new session and responses route to the most recent one; with several CLIs connecting in parallel only the last completes tools/list. Reproduces before and after this change — will file separately.

Related Issues

Fixes #28785
Related to #28789, #29088 (earlier attempts, closed without merge)

How to Validate

Unit tests

npm test -w gemini-cli-vscode-ide-companion -- src/ide-server.test.ts
# 25 tests: 24 passed | 1 skipped (win32-only)

To see the regression test fail: git stash the ide-server.ts hunk (keep the test file) and re-run — should resolve stop() while a session holds an open SSE stream times out, and 6 more in the same describe fail.

Manual, end-to-end (real IdeClient from @google/gemini-cli-core against a real IDEServer)

A vite-node harness drove the production server with the production CLI client in child processes across 7 scenarios: S1 no sessions · S2 one connected client holding the GET /mcp stream · S3 three clients · S4 in-flight openDiff request during stop() · S5 repeated and concurrent stop() · S6 restart after stop() (no stale port file, fresh listener, client reconnects) · S7 keep-alive eviction (2 misses + success + 2 misses must not evict; 3 consecutive must). Results:

Build Checks passed
main (unfixed) 24 / 49 — stop() hangs with ≥1 client; port file and env collection leaked; second concurrent stop() rejects
this PR 49 / 49 — stop() resolves in 1–7 ms with 0, 1 and 3 clients and with a request in flight; Session closed: <id> logged per session; port file removed; env collection cleared; 0 unhandled rejections

In VS Code

  1. Build and launch the companion ("Run Extension"), start gemini in the integrated terminal and let it connect (/ide status → connected).
  2. Reload the window (or run Developer: Reload Window).
  3. Expected before: deactivate blocks (extension host reports the companion unresponsive) and $TMPDIR/gemini/ide/gemini-ide-server-<extension-host-pid>-<port>.json is left behind. Expected after: deactivation completes immediately and the port file is gone.

CI parity: all ci.yml Lint-job steps and both Test (Linux) shards + bundle + npx smoke test were run locally on Node 20 — 15 950 tests, 0 failures.

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed) — no user-facing docs affected
  • Added/updated tests (if needed)
  • Noted breaking changes (if any) — none
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

…ssions are open

`stop()` awaited `http.Server.close()`, which only stops accepting new
connections and waits for existing ones to drain. The CLI's
StreamableHTTP client keeps a standalone `GET /mcp` SSE stream open for
the life of the session, so the callback never fired, `stop()` never
resolved, and the cleanup after the await (env collection, port file)
never ran.

`stop()` now:
- detaches `server`/`transports` synchronously so repeated or concurrent
  calls are no-ops instead of racing on the same listener;
- closes every MCP transport first (`Promise.allSettled`), which ends the
  SSE responses and fires `onclose` (clears keep-alive, evicts session);
- calls `server.closeAllConnections()` alongside `server.close()` so
  idle keep-alive sockets cannot hold the callback;
- runs the env-collection/port-file cleanup in `finally`.

The keep-alive `missedPings >= 3` branch now closes the transport (as
its log line already claimed) rather than only clearing the interval.
Consecutive-miss semantics are unchanged.

Fixes google-gemini#28785
@elberthc-byte
elberthc-byte requested a review from a team as a code owner October 7, 2026 23:59
@github-actions github-actions Bot added the size/l A large sized PR label Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 465
  • Additions: +447
  • Deletions: -18
  • Files changed: 2

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a regression where IdeServer.stop() would hang indefinitely due to long-lived SSE streams held by connected MCP clients. By proactively closing these transports and forcing connection termination, the server now shuts down reliably. The changes also improve the robustness of the cleanup process and ensure that the server handles concurrent shutdown requests gracefully.

Highlights

  • Improved Server Shutdown: Refactored IdeServer.stop() to explicitly close all active MCP sessions and force-close remaining sockets before shutting down the HTTP server, ensuring the method resolves promptly.
  • Robust Cleanup: Moved environment variable clearing and port file deletion into a finally block to guarantee cleanup occurs even if the server shutdown encounters an error.
  • Concurrent Safety: Added synchronous detachment of server and transport references to make stop() idempotent and safe for concurrent calls.
  • Regression Testing: Added a comprehensive suite of tests to verify shutdown behavior with active SSE streams, multiple clients, and keep-alive scenarios.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@elberthc-byte

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the IDE server shutdown process in packages/vscode-ide-companion to ensure active MCP sessions are closed, keep-alive intervals are cleared, and remaining connections are dropped promptly, making the stop() method idempotent and preventing hangs. It also adds comprehensive tests to verify these shutdown and keep-alive behaviors. The review feedback suggests handling potential promise rejections when calling transport.close() within the keep-alive interval to prevent unhandled rejections in the extension host.

Comment thread packages/vscode-ide-companion/src/ide-server.ts Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the shutdown and keep-alive mechanisms of the IDE server in packages/vscode-ide-companion to ensure active MCP sessions are properly closed and keep-alive intervals are cleared. It also adds comprehensive unit tests covering various shutdown scenarios and keep-alive behaviors. However, the current implementation of stop() introduces a race condition where concurrent calls can resolve instantly before the initial asynchronous shutdown and cleanup processes actually complete. It is recommended to manage the asynchronous shutdown state using an explicit state variable and await the active shutdown promise on concurrent calls.

Comment thread packages/vscode-ide-companion/src/ide-server.ts
- Catch and log a rejected `transport.close()` in the missed-pings branch
  instead of `void`-ing it, and clear the interval directly so a transport
  that cannot close is not retried every 60s.
- Share the in-flight shutdown promise across concurrent `stop()` calls so
  a second caller resolves only after the listener, sockets and port file
  are actually gone, rather than instantly.
…icit status

Per review: model the shutdown lifecycle with `status: 'idle' | 'stopping' |
'stopped'` instead of the presence of a promise. `stopping` joins the
in-flight shutdown, `stopped` is a no-op, and `start()` resets to `idle`
so a restarted server remains stoppable. Adds a restart test.
@gemini-cli gemini-cli Bot added the area/core Issues related to User Interface, OS Support, Core Functionality label Oct 8, 2026
@elberthc-byte

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves the IDE server's shutdown sequence by introducing state tracking ('idle', 'stopping', 'stopped') and ensuring active MCP sessions are closed and keep-alive intervals are cleared. It also adds comprehensive tests covering various shutdown scenarios and keep-alive eviction. Feedback points out two critical race conditions during rapid stop-start-stop cycles: one affecting the port file and environment variables, and another affecting the status transition. To resolve these, it is recommended to capture the port file synchronously at the start of shutdown and use an explicit shutdown identifier to safely track the active shutdown operation.

Comment thread packages/vscode-ide-companion/src/ide-server.ts
…ycles

A shutdown that finishes after a newer start() must not touch the new
server's state. Each shutdown now captures the lifecycle generation
(bumped by start()) and only marks the server stopped / clears the env
collection if it is still the current one; the port file is captured
synchronously so only the old file is unlinked.
@elberthc-byte

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the shutdown process of the IDE server in packages/vscode-ide-companion to safely handle active Model Context Protocol (MCP) sessions and concurrent shutdown requests. It introduces a state machine (status), a generation counter to prevent late-finishing shutdowns from clobbering restarted servers, and ensures active transports are closed and connections are drained promptly during shutdown. Additionally, comprehensive unit tests have been added to verify these behaviors, including keep-alive ping eviction and concurrent stop scenarios. I have no further feedback to provide as no review comments were submitted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Issues related to User Interface, OS Support, Core Functionality size/l A large sized PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IdeServer.stop() never resolves while an MCP session is open

1 participant