Skip to content
Prev Previous commit
Next Next commit
Transition to disconnected state on unexpected process/connection death
All SDKs now properly transition their connection state to 'disconnected'
when the child process exits unexpectedly or the TCP connection drops:

- Node.js: onClose/onError handlers in attachConnectionHandlers()
- Go: onClose callback fired from readLoop() on unexpected exit
- Python: on_close callback fired from _read_loop() on unexpected exit
- .NET: rpc.Completion continuation sets _disconnected flag

Includes unit tests for all four SDKs verifying the state transition.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
  • Loading branch information
SteveSandersonMS and Copilot committed Mar 12, 2026
commit 8f1ae3dcd704dddd4bd8513a355d7a7f02c42bc8
6 changes: 6 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly CopilotClientOptions _options;
private readonly ILogger _logger;
private Task<Connection>? _connectionTask;
private volatile bool _disconnected;
private bool _disposed;
private readonly int? _optionsPort;
private readonly string? _optionsHost;
Expand Down Expand Up @@ -199,6 +200,7 @@ public Task StartAsync(CancellationToken cancellationToken = default)
async Task<Connection> StartCoreAsync(CancellationToken ct)
{
_logger.LogDebug("Starting Copilot client");
_disconnected = false;

Task<Connection> result;

Expand Down Expand Up @@ -590,6 +592,7 @@ public ConnectionState State
if (_connectionTask == null) return ConnectionState.Disconnected;
if (_connectionTask.IsFaulted) return ConnectionState.Error;
if (!_connectionTask.IsCompleted) return ConnectionState.Connecting;
if (_disconnected) return ConnectionState.Disconnected;
return ConnectionState.Connected;
}
}
Expand Down Expand Up @@ -1198,6 +1201,9 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
rpc.StartListening();

// Transition state to Disconnected if the JSON-RPC connection drops
_ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default);

_rpc = new ServerRpc(rpc);

return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer);
Expand Down
38 changes: 38 additions & 0 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,42 @@ public async Task ListModels_WithCustomHandler_WorksWithoutStart()
Assert.Single(models);
Assert.Equal("no-start-model", models[0].Id);
}

[Fact]
public async Task State_Should_Transition_To_Disconnected_When_Process_Is_Killed()
{
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true });

try
{
await client.StartAsync();
Assert.Equal(ConnectionState.Connected, client.State);

// Use reflection to reach the child process inside the private Connection object
var taskField = typeof(CopilotClient)
.GetField("_connectionTask", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var task = (Task)taskField.GetValue(client)!;
await task; // ensure it's completed
// Task<Connection>.Result via reflection
var resultProp = task.GetType().GetProperty("Result")!;
var connection = resultProp.GetValue(task)!;
var processProp = connection.GetType().GetProperty("CliProcess")!;
var process = (System.Diagnostics.Process)processProp.GetValue(connection)!;

process.Kill();

// Wait for ContinueWith callback to set _disconnected
for (var i = 0; i < 50; i++)
{
if (client.State == ConnectionState.Disconnected) break;
await Task.Delay(100);
}

Assert.Equal(ConnectionState.Disconnected, client.State);
}
finally
{
try { await client.ForceStopAsync(); } catch { /* process already dead */ }
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}
}
}
10 changes: 10 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,11 @@ func (c *Client) startCLIServer(ctx context.Context) error {
// Create JSON-RPC client immediately
c.client = jsonrpc2.NewClient(stdin, stdout)
c.client.SetProcessDone(c.processDone, c.processErrorPtr)
c.client.SetOnClose(func() {
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
c.state = StateDisconnected
})
c.RPC = rpc.NewServerRpc(c.client)
c.setupNotificationHandler()
c.client.Start()
Expand Down Expand Up @@ -1342,6 +1347,11 @@ func (c *Client) connectViaTcp(ctx context.Context) error {
if c.processDone != nil {
c.client.SetProcessDone(c.processDone, c.processErrorPtr)
}
c.client.SetOnClose(func() {
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
c.state = StateDisconnected
})
c.RPC = rpc.NewServerRpc(c.client)
c.setupNotificationHandler()
c.client.Start()
Expand Down
14 changes: 14 additions & 0 deletions go/internal/jsonrpc2/jsonrpc2.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type Client struct {
processDone chan struct{} // closed when the underlying process exits
processError error // set before processDone is closed
processErrorMu sync.RWMutex // protects processError
onClose func() // called when the read loop exits unexpectedly
}

// NewClient creates a new JSON-RPC client
Expand Down Expand Up @@ -293,9 +294,22 @@ func (c *Client) sendMessage(message any) error {
return nil
}

// SetOnClose sets a callback invoked when the read loop exits unexpectedly
// (e.g. the underlying connection or process was lost).
func (c *Client) SetOnClose(fn func()) {
c.onClose = fn
}

// readLoop reads messages from stdout in a background goroutine
func (c *Client) readLoop() {
defer c.wg.Done()
defer func() {
// If still running, the read loop exited unexpectedly (process died or
// connection dropped). Notify the caller so it can update its state.
if c.onClose != nil && c.running.Load() {
c.onClose()
}
}()

reader := bufio.NewReader(c.stdout)

Expand Down
69 changes: 69 additions & 0 deletions go/internal/jsonrpc2/jsonrpc2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package jsonrpc2

import (
"io"
"sync"
"testing"
"time"
)

func TestOnCloseCalledOnUnexpectedExit(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
defer stdinR.Close()

client := NewClient(stdinW, stdoutR)

var called bool
var mu sync.Mutex
client.SetOnClose(func() {
mu.Lock()
called = true
mu.Unlock()
})

client.Start()

// Simulate unexpected process death by closing the stdout writer
stdoutW.Close()

// Wait for readLoop to detect the close and invoke the callback
time.Sleep(200 * time.Millisecond)

mu.Lock()
defer mu.Unlock()
if !called {
t.Error("expected onClose to be called when read loop exits unexpectedly")
}
}

func TestOnCloseNotCalledOnIntentionalStop(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
defer stdinR.Close()
defer stdoutW.Close()

client := NewClient(stdinW, stdoutR)

var called bool
var mu sync.Mutex
client.SetOnClose(func() {
mu.Lock()
called = true
mu.Unlock()
})

client.Start()

// Intentional stop — should set running=false before closing stdout,
// so the readLoop should NOT invoke onClose.
client.Stop()

time.Sleep(200 * time.Millisecond)

mu.Lock()
defer mu.Unlock()
if called {
t.Error("onClose should not be called on intentional Stop()")
}
}
19 changes: 19 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,4 +484,23 @@ describe("CopilotClient", () => {
expect(models).toEqual(customModels);
});
});

describe("unexpected disconnection", () => {
it("transitions to disconnected when child process is killed", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => client.forceStop());

expect(client.getState()).toBe("connected");

// Kill the child process to simulate unexpected termination
const proc = (client as any).cliProcess as import("node:child_process").ChildProcess;
proc.kill();

// Wait for the connection.onClose handler to fire
await vi.waitFor(() => {
expect(client.getState()).toBe("disconnected");
});
});
});
});
2 changes: 2 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,7 @@ async def _connect_via_stdio(self) -> None:

# Create JSON-RPC client with the process
self._client = JsonRpcClient(self._process)
self._client.on_close = lambda: setattr(self, '_state', 'disconnected')
self._rpc = ServerRpc(self._client)

# Set up notification handler for session events
Expand Down Expand Up @@ -1492,6 +1493,7 @@ def wait(self, timeout=None):

self._process = SocketWrapper(sock_file, sock) # type: ignore
self._client = JsonRpcClient(self._process)
self._client.on_close = lambda: setattr(self, '_state', 'disconnected')
self._rpc = ServerRpc(self._client)

# Set up notification handler for session events
Expand Down
3 changes: 3 additions & 0 deletions python/copilot/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def __init__(self, process):
self._process_exit_error: str | None = None
self._stderr_output: list[str] = []
self._stderr_lock = threading.Lock()
self.on_close: Callable[[], None] | None = None

def start(self, loop: asyncio.AbstractEventLoop | None = None):
"""Start listening for messages in background thread"""
Expand Down Expand Up @@ -211,6 +212,8 @@ def _read_loop(self):
# Process exited or read failed - fail all pending requests
if self._running:
self._fail_pending_requests()
if self.on_close is not None:
self.on_close()

def _fail_pending_requests(self):
"""Fail all pending requests when process exits"""
Expand Down
62 changes: 62 additions & 0 deletions python/test_jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import io
import json
import os
import threading
import time

import pytest

Expand Down Expand Up @@ -265,3 +268,62 @@ def test_read_message_multiple_messages_in_sequence(self):

result2 = client._read_message()
assert result2 == message2


class ClosingStream:
"""Stream that immediately returns empty bytes (simulates process death / EOF)."""

def readline(self):
return b""

def read(self, n: int) -> bytes:
return b""


class TestOnClose:
"""Tests for the on_close callback when the read loop exits unexpectedly."""

def test_on_close_called_on_unexpected_exit(self):
"""on_close fires when the stream closes while client is still running."""
import asyncio

process = MockProcess()
process.stdout = ClosingStream()

client = JsonRpcClient(process)

called = threading.Event()
client.on_close = lambda: called.set()

loop = asyncio.new_event_loop()
try:
client.start(loop=loop)
assert called.wait(timeout=2), "on_close was not called within 2 seconds"
finally:
loop.close()

def test_on_close_not_called_on_intentional_stop(self):
"""on_close should not fire when stop() is called intentionally."""
import asyncio

r_fd, w_fd = os.pipe()
process = MockProcess()
process.stdout = os.fdopen(r_fd, "rb")

client = JsonRpcClient(process)

called = threading.Event()
client.on_close = lambda: called.set()

loop = asyncio.new_event_loop()
try:
client.start(loop=loop)

# Intentional stop sets _running = False before the thread sees EOF
loop.run_until_complete(client.stop())
os.close(w_fd)

time.sleep(0.5)
assert not called.is_set(), "on_close should not be called on intentional stop"
finally:
loop.close()
Loading