Skip to content

Fix orphaned lsp_server.py processes accumulating on Remote SSH hosts#258

Draft
Copilot wants to merge 2 commits intomainfrom
copilot/fix-orphan-lsp-server-processes
Draft

Fix orphaned lsp_server.py processes accumulating on Remote SSH hosts#258
Copilot wants to merge 2 commits intomainfrom
copilot/fix-orphan-lsp-server-processes

Conversation

Copy link
Contributor

Copilot AI commented Feb 25, 2026

On abrupt disconnects (network drop, crash), deactivate() is never called, leaving lsp_server.py processes running indefinitely. After N reconnects, N orphaned servers run simultaneously, causing duplicate handlers and garbled edits.

Changes

  • bundled/tool/lsp_server.py — stdin watchdog
    Daemon thread monitors stdin for EOF/closure; calls os._exit(0) when the extension host pipe dies. Uses select() on Unix for immediate EOF detection, polling fallback on Windows.

    def _stdin_watchdog() -> None:
        while True:
            try:
                if sys.stdin.closed:
                    os._exit(0)
                if hasattr(select, 'select'):
                    readable, _, _ = select.select([sys.stdin], [], [], 5.0)
                    if readable and sys.stdin.read(1) == "":
                        os._exit(0)
                ...
            except Exception:
                os._exit(0)
    
    if __name__ == "__main__":
        watchdog = threading.Thread(target=_stdin_watchdog, daemon=True)
        watchdog.start()
        LSP_SERVER.start_io()
  • src/extension.ts — restart concurrency guard
    Module-level _isRestarting flag short-circuits concurrent runServer() invocations (e.g., interpreter change and config change firing simultaneously), preventing multiple servers from being spawned in a race.

  • src/common/server.ts — bounded stop on restart
    lsClient.stop() now has a 2-second timeout via lsClient.stop(2000).catch(...), so a hung or already-dead server process doesn't block the restart path indefinitely.

Original prompt

Problem

When using extensions built from this template via VS Code Remote SSH, orphaned lsp_server.py processes accumulate on the remote host over time. This is reported downstream in microsoft/vscode-isort#463 and affects all extensions built from this template (vscode-isort, vscode-black-formatter, vscode-mypy, vscode-pylint, etc.).

Root Cause

The server lifecycle management in src/extension.ts and src/common/server.ts has two gaps:

  1. No orphan cleanup on activation: When activate() runs, lsClient starts as undefined (fresh module context). If VS Code disconnected abruptly in a previous session (network drop, crash, window close without graceful shutdown), deactivate() was never called, and the old lsp_server.py process remains running as an orphan. The new activate() has no way to discover or kill the leftover process.

  2. No stdin-close detection in the Python server: The lsp_server.py process communicates via stdio. When the extension host process dies, the stdin pipe closes, but pygls may not immediately detect this and exit. The server can remain running indefinitely as an orphan.

After N disconnect/reconnect cycles, N orphaned server processes run simultaneously, all responding to LSP requests, causing duplicated "Organize Imports" handlers, duplicated lines on save, and garbled edits.

Required Changes

1. bundled/tool/lsp_server.py — Add stdin watchdog

Add a background thread that periodically checks if stdin is still open/connected. If stdin is closed (meaning the extension host has died), the server should exit gracefully. This ensures orphaned servers self-terminate rather than running forever.

Example approach:

import threading

def _stdin_watchdog():
    """Watch for stdin closure and exit if the parent process is gone."""
    import select
    while True:
        try:
            # Check if stdin is still readable/valid
            if sys.stdin.closed:
                log_to_output("stdin closed, shutting down server.")
                os._exit(0)
            # Also try reading with a timeout — on Unix, a closed pipe will return EOF
            if hasattr(select, 'select'):
                readable, _, _ = select.select([sys.stdin], [], [], 5.0)
                # If stdin is in the readable set but read returns empty, the pipe is broken
            else:
                import time
                time.sleep(5)
                if sys.stdin.closed:
                    os._exit(0)
        except Exception:
            os._exit(0)

Start this thread before LSP_SERVER.start_io():

if __name__ == "__main__":
    watchdog = threading.Thread(target=_stdin_watchdog, daemon=True)
    watchdog.start()
    LSP_SERVER.start_io()

2. src/common/server.ts — Track server process PID and clean up stale processes on restart

When restartServer is called, if there is an existing lsClient, ensure the underlying process is properly killed (not just asked to stop). The LanguageClient from vscode-languageclient provides access to the server process — add a timeout to lsClient.stop() and forcefully kill the process if it doesn't stop in time.

Additionally, consider storing the PID of the spawned server process (e.g., via globalState or a file) so that on the next activate(), stale processes from previous sessions can be detected and killed.

3. src/extension.ts — Guard against concurrent runServer() calls

Add a mutex/flag to prevent concurrent runServer() invocations from racing and spawning multiple servers. The downstream vscode-isort added an isRestarting guard — this pattern should be upstreamed to the template.

Example:

let isRestarting = false;
const runServer = async () => {
    if (isRestarting) {
        return;
    }
    isRestarting = true;
    try {
        // ... existing runServer logic ...
    } finally {
        isRestarting = false;
    }
};

Acceptance Criteria

  • Orphaned lsp_server.py processes should self-terminate when their parent extension host dies
  • Multiple rapid runServer() calls should not spawn duplicate servers
  • The fix should be robust on both Linux/macOS (Remote SSH targets) and Windows
  • Existing functionality (restart command, interpreter change, configuration change) must continue to work

This pull request was created from Copilot chat.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

…stop timeout

Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix orphaned lsp_server.py processes on activation Fix orphaned lsp_server.py processes accumulating on Remote SSH hosts Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants