CopilotClient.stop() leaks the CLI server's child process tree on Windows (orphaned node/copilot.exe per session)

Open
#1,804 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
52/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Quiet
Tech stack
python

Research direction

Inspect client.py, especially CopilotClient.stop() and StdioRuntimeConnection process creation, then reproduce the Windows create_session()/stop() loop from the issue while tracking descendant processes. Compare the lifecycle behavior across Windows and POSIX, and consider the stated Job Object or process-group approaches. Done means stopping a spawned client leaves no orphaned node.exe or copilot.exe descendants.

Written by the indexing model from the issue text.

Description

bug
Summary

On Windows, CopilotClient.stop() does not terminate the CLI server's child process tree — only the top-level launcher process. Every create_session() / stop() cycle therefore orphans a full copilot process tree (node.exe + the copilot.exe broker/worker/webview helpers) that survives until its own idle timeout. In a long-running app that creates one client/session per unit of work (e.g. a batch/eval harness), this is a deterministic ~1 leaked process tree per session, which accumulates and eventually OOMs the host.

Environment
  • github-copilot-sdk (Python) 1.0.0b7 (also visible by inspection in current client.py)
  • Copilot CLI runtime 1.0.66-0
  • Windows 11, Python 3.11 (miniconda)
  • Transport: StdioRuntimeConnection, use_logged_in_user=True
Root cause

CopilotClient.stop() only calls terminate() / kill() on its own launcher Popen (self._process):

# client.py (stop)
if self._process and not self._is_external_server:
    self._process.terminate()
    try:
        self._process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        self._process.kill()
    self._process = None

On Windows, terminating the launcher does not cascade to its descendants. The launcher (copilot.cmdcmd.exe) spawns node.exe, which spawns the real copilot.exe server + helper processes; these are left orphaned and alive after stop().

Reproduction
import asyncio, psutil
from copilot import CopilotClient, StdioRuntimeConnection

CLI = r"C:\Users\<you>\AppData\Roaming\npm\copilot.cmd"

def n_copilot():
    return sum(p.info["name"] == "copilot.exe"
               for p in psutil.process_iter(["name"]))

async def main():
    for i in range(5):
        client = CopilotClient(
            connection=StdioRuntimeConnection(CLI, []),
            use_logged_in_user=True,
        )
        session = await client.create_session()
        await session.send("hello")
        # ...consume events until SessionIdle...
        await session.disconnect()
        await client.stop()
        print(f"iter {i}: copilot.exe alive = {n_copilot()}")

asyncio.run(main())

Observed (Windows): copilot.exe alive grows 1, 2, 3, 4, 5 — one orphaned tree per iteration, none reaped by stop().

Expected

After await client.stop() (for a client that spawned the server), the entire CLI server process tree should be terminated, leaving no orphaned node.exe / copilot.exe processes.

Workaround

Capture the launcher PID after create_session() and kill the whole tree explicitly on teardown — e.g. psutil.Process(pid).children(recursive=True) + kill all (enumerate before killing the parent), or taskkill /F /T /PID on Windows / os.killpg on POSIX.

Suggested fix

Have the SDK own the process-tree lifecycle so stop() reaps descendants:

  • Windows: assign the launcher to a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so the OS atomically kills the tree when the job/handle closes.
  • POSIX: spawn with start_new_session=True and os.killpg(os.getpgid(pid), SIGKILL) on stop.
  • Or a psutil-based recursive child kill inside stop().

This is likely related to the CLI-side reports of orphaned processes (e.g. github/copilot-cli#1368, #2279) but is reproducible purely through the SDK's own create_session()/stop() lifecycle.

Dominant language
Java
Stars
10.5k
Forks
1.5k
Avg merge
1d 12h
Merged PRs (30d)
133

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from github/copilot-sdk

All issues in github/copilot-sdk

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.