` directly.
+## JSON-RPC errors
+
+Use `errors.As` to inspect a runtime error without parsing its message, including
+errors wrapped by SDK operations:
+
+```go
+var rpcErr *copilot.RPCError
+if errors.As(err, &rpcErr) {
+ fmt.Printf("RPC error %d: %s\n", rpcErr.Code, rpcErr.Message)
+ if rpcErr.Data != nil {
+ // Decode into an application-specific type when the payload schema is known.
+ var details map[string]json.RawMessage
+ if err := json.Unmarshal(rpcErr.Data, &details); err != nil {
+ // The payload may be an array or scalar rather than an object.
+ log.Printf("Error data is not an object: %v", err)
+ }
+ }
+}
+```
+
+This example uses the standard `errors` and `encoding/json` packages.
+`RPCError.Data` is a `json.RawMessage` containing the original JSON value:
+objects, arrays, strings, numbers, and booleans are preserved. Omitted `data`
+is `nil`; explicit JSON null is the non-nil JSON text `null`. Empty values,
+zero, and false are not treated as absent. Non-RPC failures do not match
+`*copilot.RPCError`.
+
+`RPCError` aliases the existing transport error, so error identity, wrapping,
+and `Error()` messages are unchanged. The error string does not include the
+payload; accessing or logging it is an explicit application choice.
+
## Distributing your application with an embedded GitHub Copilot CLI
The SDK supports bundling, using Go's `embed` package, the Copilot CLI binary within your application's distribution.
diff --git a/go/errors.go b/go/errors.go
new file mode 100644
index 0000000000..7babdcb1f8
--- /dev/null
+++ b/go/errors.go
@@ -0,0 +1,15 @@
+package copilot
+
+import "github.com/github/copilot-sdk/go/internal/jsonrpc2"
+
+// RPCError is an error response from the runtime's JSON-RPC API.
+// Use errors.As to retrieve it from errors wrapped by SDK operations.
+//
+// Code and Message contain the JSON-RPC error code and message. Data contains
+// the optional JSON value, which can be an object, array, or scalar. Omitted
+// data is nil; explicit JSON null is the JSON text "null". Error() does not
+// include Data.
+//
+// RPCError is an alias of the transport error, preserving its identity and
+// existing wrapping behavior.
+type RPCError = jsonrpc2.Error
diff --git a/go/errors_test.go b/go/errors_test.go
new file mode 100644
index 0000000000..3ceed1c338
--- /dev/null
+++ b/go/errors_test.go
@@ -0,0 +1,216 @@
+package copilot_test
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ copilot "github.com/github/copilot-sdk/go"
+)
+
+func TestRPCErrorData(t *testing.T) {
+ payloads := []struct {
+ name string
+ data string
+ }{
+ {"object", `{"reason":"private detail","nested":{"values":[1,null,false]}}`},
+ {"array", `[1,"private detail",{"nested":true}]`},
+ {"string", `"private detail"`},
+ {"integer", `9007199254740993`},
+ {"fraction", `1.25`},
+ {"zero", `0`},
+ {"true", `true`},
+ {"false", `false`},
+ {"empty object", `{}`},
+ {"empty array", `[]`},
+ {"empty string", `""`},
+ {"omitted", ``},
+ {"null", `null`},
+ }
+
+ for _, payload := range payloads {
+ t.Run(payload.name, func(t *testing.T) {
+ client := newRPCErrorClient(t, payload.data)
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ defer cancel()
+
+ for _, method := range []string{"status.get", "session.create", "models.list"} {
+ var err error
+ switch method {
+ case "session.create":
+ _, err = client.CreateSession(ctx, &copilot.SessionConfig{})
+ case "status.get":
+ _, err = client.GetStatus(ctx)
+ case "models.list":
+ _, err = client.RPC.Models.List(ctx, nil)
+ }
+
+ var rpcErr *copilot.RPCError
+ if !errors.As(err, &rpcErr) {
+ t.Fatalf("%s: expected RPCError, got %T: %v", method, err, err)
+ }
+ if rpcErr.Code != -32000 || rpcErr.Message != "request rejected" {
+ t.Fatalf("unexpected RPC error: %+v", rpcErr)
+ }
+ if string(rpcErr.Data) != payload.data {
+ t.Fatalf("data = %s, want %s", rpcErr.Data, payload.data)
+ }
+ if (rpcErr.Data == nil) != (payload.data == "") {
+ t.Fatalf("data presence = %v, want %v", rpcErr.Data != nil, payload.data != "")
+ }
+ want := "JSON-RPC Error -32000: request rejected"
+ if rpcErr.Error() != want {
+ t.Fatalf("RPC error string = %q, want %q", rpcErr.Error(), want)
+ }
+ if method == "session.create" {
+ want = "failed to create session: " + want
+ if errors.Unwrap(err) != rpcErr {
+ t.Fatal("SDK wrapper did not preserve the RPC error identity")
+ }
+ } else if err != rpcErr {
+ t.Fatal("direct call changed the RPC error identity")
+ }
+ if err.Error() != want {
+ t.Fatalf("error string = %q, want %q", err.Error(), want)
+ }
+ outer := fmt.Errorf("application context: %w", err)
+ var recovered *copilot.RPCError
+ if !errors.As(outer, &recovered) || recovered != rpcErr {
+ t.Fatal("application wrapper did not preserve errors.As access")
+ }
+ }
+
+ response, err := client.Ping(ctx, "still connected")
+ if err != nil || response.Message != "still connected" {
+ t.Fatalf("successful response after RPC errors: response=%+v, err=%v", response, err)
+ }
+ })
+ }
+}
+
+func TestRPCErrorNonRPCFailure(t *testing.T) {
+ client := copilot.NewClient(&copilot.ClientOptions{})
+ _, err := client.GetStatus(t.Context())
+ var rpcErr *copilot.RPCError
+ if err == nil || errors.As(err, &rpcErr) {
+ t.Fatalf("expected a non-RPC error, got %T: %v", err, err)
+ }
+}
+
+// Use a raw framed peer so these tests exercise the public API without importing
+// the SDK's internal transport or re-encoding the error through RPCError.
+func newRPCErrorClient(t *testing.T, data string) *copilot.Client {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() {
+ done <- serveRPCErrorPeer(listener, data)
+ }()
+ t.Cleanup(func() {
+ listener.Close()
+ select {
+ case err := <-done:
+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
+ t.Errorf("RPC peer: %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Error("RPC peer did not stop")
+ }
+ })
+
+ client := copilot.NewClient(&copilot.ClientOptions{
+ Connection: copilot.URIConnection{URL: listener.Addr().String()},
+ })
+ t.Cleanup(client.ForceStop)
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ defer cancel()
+ if err := client.Start(ctx); err != nil {
+ t.Fatal(err)
+ }
+ return client
+}
+
+func serveRPCErrorPeer(listener net.Listener, data string) error {
+ conn, err := listener.Accept()
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
+ return err
+ }
+ reader := bufio.NewReader(conn)
+ for {
+ body, err := readRPCErrorFrame(reader)
+ if err != nil {
+ return err
+ }
+ var request struct {
+ ID json.RawMessage `json:"id"`
+ Method string `json:"method"`
+ }
+ if err := json.Unmarshal(body, &request); err != nil {
+ return err
+ }
+ if len(request.ID) == 0 {
+ continue
+ }
+ var response string
+ switch request.Method {
+ case "connect":
+ response = `"result":{"ok":true,"protocolVersion":3,"version":"test"}`
+ case "status.get", "session.create", "models.list":
+ response = `"error":{"code":-32000,"message":"request rejected"`
+ if data != "" {
+ response += `,"data":` + data
+ }
+ response += `}`
+ case "ping":
+ response = `"result":{"message":"still connected","timestamp":"2026-01-01T00:00:00Z"}`
+ default:
+ return fmt.Errorf("unexpected method %q", request.Method)
+ }
+ frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,%s}`, request.ID, response)
+ if _, err := fmt.Fprintf(conn, "Content-Length: %d\r\n\r\n%s", len(frame), frame); err != nil {
+ return err
+ }
+ }
+}
+
+func readRPCErrorFrame(reader *bufio.Reader) ([]byte, error) {
+ length := 0
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return nil, err
+ }
+ line = strings.TrimSpace(line)
+ if line == "" {
+ break
+ }
+ name, value, ok := strings.Cut(line, ":")
+ if ok && name == "Content-Length" {
+ length, err = strconv.Atoi(strings.TrimSpace(value))
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ if length <= 0 || length > 1024*1024 {
+ return nil, fmt.Errorf("invalid Content-Length %d", length)
+ }
+ body := make([]byte, length)
+ _, err := io.ReadFull(reader, body)
+ return body, err
+}
diff --git a/java/README.md b/java/README.md
index f3381e3255..014a01252a 100644
--- a/java/README.md
+++ b/java/README.md
@@ -488,6 +488,59 @@ var resumed = client.resumeSession(sessionId, new ResumeSessionConfig()
When `memory` is left unset, no memory configuration is sent and the runtime default applies. In the default `CopilotClientMode.COPILOT_CLI` the SDK leaves `memory` unset so the runtime applies its own default, while `CopilotClientMode.EMPTY` defaults `memory` to disabled unless you set it explicitly.
+## JSON-RPC error handling
+
+Server error responses surface as `com.github.copilot.JsonRpcException`, a
+`RuntimeException` with `getCode()`, `getMessage()`, and `getData()`. The data is a
+Jackson `JsonNode`: objects, arrays, strings, numbers, and booleans retain their
+JSON types, including empty values, zero, and false. Numeric fidelity follows
+Jackson's existing parser. Omitted data returns Java `null`; explicit JSON `null`
+returns a `NullNode` (`data.isNull()` is true).
+
+Future wrapping is unchanged. With `get()`, inspect the cause of
+`ExecutionException`:
+
+```java
+import com.github.copilot.JsonRpcException;
+import java.util.concurrent.ExecutionException;
+
+try {
+ client.ping("hello").get();
+} catch (ExecutionException ex) {
+ if (ex.getCause() instanceof JsonRpcException rpcError) {
+ System.err.println("RPC " + rpcError.getCode() + ": " + rpcError.getMessage());
+ var data = rpcError.getData();
+ if (data != null && !data.isNull()) {
+ // Inspect data according to the server's error contract.
+ }
+ } else {
+ throw ex; // Transport and local failures are not JSON-RPC error responses.
+ }
+}
+```
+
+The enclosing method must also handle or declare `InterruptedException`.
+With `join()`, the wrapper is `CompletionException` instead:
+
+```java
+import java.util.concurrent.CompletionException;
+
+try {
+ client.ping("hello").join();
+} catch (CompletionException ex) {
+ if (ex.getCause() instanceof JsonRpcException rpcError) {
+ System.err.println("RPC " + rpcError.getCode() + ": " + rpcError.getMessage());
+ var data = rpcError.getData();
+ // Java null means omitted; data.isNull() means an explicit JSON null.
+ } else {
+ throw ex;
+ }
+}
+```
+
+Error data is not appended to `getMessage()` or `toString()`. Avoid logging it
+indiscriminately: server-provided data may contain sensitive information.
+
## Using experimental APIs
Some SDK APIs are marked as experimental with `@CopilotExperimental`. These APIs may change or be removed in future versions without notice.
diff --git a/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java b/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java
index f78ce00425..8f50f4f8e6 100644
--- a/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java
+++ b/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java
@@ -375,7 +375,8 @@ private void handleMessage(String content) {
? errorNode.get("message").asText()
: "Unknown error";
int errorCode = errorNode.has("code") ? errorNode.get("code").asInt() : -1;
- future.completeExceptionally(new JsonRpcException(errorCode, errorMessage));
+ future.completeExceptionally(
+ new JsonRpcException(errorCode, errorMessage, errorNode.get("data")));
} else {
future.complete(node.get("result"));
}
diff --git a/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java b/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java
index 1786466bdd..7e8b00234f 100644
--- a/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java
+++ b/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java
@@ -4,18 +4,25 @@
package com.github.copilot;
+import com.fasterxml.jackson.databind.JsonNode;
+
/**
* Exception thrown when a JSON-RPC error occurs during communication with the
* Copilot CLI server.
*
* This exception wraps error responses from the JSON-RPC protocol, including
- * the error code and message returned by the server.
+ * the error code, message, and optional raw JSON data returned by the server.
+ * Calls to {@link java.util.concurrent.CompletableFuture#get()} wrap this
+ * exception in an {@link java.util.concurrent.ExecutionException}; calls to
+ * {@link java.util.concurrent.CompletableFuture#join()} wrap it in a
+ * {@link java.util.concurrent.CompletionException}.
*
* @since 1.0.0
*/
-final class JsonRpcException extends RuntimeException {
+public final class JsonRpcException extends RuntimeException {
private final int code;
+ private final JsonNode data;
/**
* Creates a new JSON-RPC exception.
@@ -26,8 +33,25 @@ final class JsonRpcException extends RuntimeException {
* the error message from the server
*/
public JsonRpcException(int code, String message) {
+ this(code, message, null);
+ }
+
+ /**
+ * Creates a new JSON-RPC exception with optional raw JSON error data.
+ *
+ * @param code
+ * the JSON-RPC error code
+ * @param message
+ * the error message from the server
+ * @param data
+ * the error data, or {@code null} if omitted; a JSON null is
+ * represented by a
+ * {@link com.fasterxml.jackson.databind.node.NullNode}
+ */
+ public JsonRpcException(int code, String message, JsonNode data) {
super(message);
this.code = code;
+ this.data = data;
}
/**
@@ -47,4 +71,18 @@ public JsonRpcException(int code, String message) {
public int getCode() {
return code;
}
+
+ /**
+ * Returns the raw JSON error data without converting its value or shape.
+ *
+ * An omitted data member is represented by Java {@code null}. An explicit JSON
+ * null is represented by a
+ * {@link com.fasterxml.jackson.databind.node.NullNode}. This value is not
+ * included in the exception's message or string representation.
+ *
+ * @return the error data, or {@code null} if the server omitted it
+ */
+ public JsonNode getData() {
+ return data;
+ }
}
diff --git a/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java
new file mode 100644
index 0000000000..7cb8c685fa
--- /dev/null
+++ b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java
@@ -0,0 +1,236 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.consumer;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.ByteArrayInputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.copilot.CopilotClient;
+import com.github.copilot.JsonRpcException;
+import com.github.copilot.rpc.CopilotClientOptions;
+
+@Timeout(15)
+class JsonRpcErrorDataTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final int ERROR_CODE = -32001;
+ private static final String ERROR_MESSAGE = "Request failed";
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"{\"reason\":\"details-not-in-message\",\"nested\":{\"items\":[1,false,null]}}",
+ "[{\"nested\":[\"value\"]},2,false,null]", "\"detail — unicode\"", "42", "9007199254740993", "1.25", "0",
+ "true", "false", "{}", "[]", "\"\"", "null"})
+ void preservesRawDataThroughPublicClient(String dataJson) throws Exception {
+ try (var server = new FramedServer(Reply.ERROR, dataJson);
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) {
+ client.start().get(5, TimeUnit.SECONDS);
+ var future = client.ping("error");
+
+ var getFailure = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
+ var error = assertInstanceOf(JsonRpcException.class, getFailure.getCause());
+ assertEquals(JsonRpcException.class, error.getClass());
+ assertInstanceOf(RuntimeException.class, error);
+ assertEquals(ERROR_CODE, error.getCode());
+ assertEquals(ERROR_MESSAGE, error.getMessage());
+ assertEquals("com.github.copilot.JsonRpcException: Request failed", error.toString());
+ if (dataJson == null) {
+ assertNull(error.getData());
+ } else {
+ assertEquals(MAPPER.readTree(dataJson), error.getData());
+ }
+
+ var joinFailure = assertThrows(CompletionException.class, future::join);
+ assertSame(error, joinFailure.getCause());
+ }
+ }
+
+ @Test
+ void preservesSuccessfulResponses() throws Exception {
+ try (var server = new FramedServer(Reply.SUCCESS, null);
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) {
+ client.start().get(5, TimeUnit.SECONDS);
+ assertEquals("hello", client.ping("hello").get(5, TimeUnit.SECONDS).message());
+ }
+ }
+
+ @Test
+ void localFailuresRemainDistinctFromRemoteErrors() {
+ try (var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false))) {
+ RuntimeException failure = assertThrows(IllegalStateException.class, () -> client.ping("not connected"));
+ assertEquals("Client not connected. Call start() first.", failure.getMessage());
+ assertFalse(failure instanceof JsonRpcException);
+ }
+ }
+
+ @Test
+ void preservesConstructorCompatibility() throws Exception {
+ var withoutData = new JsonRpcException(ERROR_CODE, ERROR_MESSAGE);
+ assertNull(withoutData.getData());
+ assertEquals(ERROR_CODE, withoutData.getCode());
+ assertEquals(ERROR_MESSAGE, withoutData.getMessage());
+ var data = MAPPER.readTree("{\"detail\":false}");
+ var withData = new JsonRpcException(ERROR_CODE, ERROR_MESSAGE, data);
+ assertSame(data, withData.getData());
+ assertEquals(withoutData.toString(), withData.toString());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"not-a-number", "2147483648"})
+ void malformedFrameLengthIsAnIoFailure(String length) {
+ var frame = ("Content-Length: " + length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII);
+ var error = assertThrows(IOException.class, () -> FramedServer.readFrame(new ByteArrayInputStream(frame)));
+ assertEquals("Invalid Content-Length: " + length, error.getMessage());
+ assertInstanceOf(NumberFormatException.class, error.getCause());
+ }
+
+ private enum Reply {
+ ERROR, SUCCESS
+ }
+
+ /**
+ * A framed loopback peer that uses no SDK internals or CLI runtime.
+ */
+ private static final class FramedServer implements AutoCloseable {
+
+ private final ServerSocket listener;
+ private final Thread worker;
+ private final CompletableFuture finished = new CompletableFuture<>();
+ private volatile Socket socket;
+ private volatile boolean closing;
+
+ FramedServer(Reply reply, String dataJson) throws IOException {
+ listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
+ listener.setSoTimeout(5000);
+ worker = new Thread(() -> serve(reply, dataJson), "json-rpc-error-data-peer");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ String url() {
+ return "127.0.0.1:" + listener.getLocalPort();
+ }
+
+ private void serve(Reply reply, String dataJson) {
+ try (var accepted = listener.accept()) {
+ socket = accepted;
+ accepted.setSoTimeout(5000);
+ while (!closing) {
+ JsonNode request = readFrame(accepted.getInputStream());
+ if (request == null) {
+ break;
+ }
+ if (!request.has("id")) {
+ continue;
+ }
+ var response = MAPPER.createObjectNode().put("jsonrpc", "2.0");
+ response.set("id", request.get("id"));
+ switch (request.path("method").asText()) {
+ case "connect" -> response.putObject("result").put("protocolVersion", 2);
+ case "ping" -> {
+ if (reply == Reply.SUCCESS) {
+ response.putObject("result")
+ .put("message", request.path("params").path("message").asText())
+ .put("protocolVersion", 2);
+ } else {
+ var error = response.putObject("error").put("code", ERROR_CODE).put("message",
+ ERROR_MESSAGE);
+ if (dataJson != null) {
+ error.set("data", MAPPER.readTree(dataJson));
+ }
+ }
+ }
+ case "runtime.shutdown" -> response.putObject("result");
+ default -> throw new IOException("Unexpected method: " + request.path("method"));
+ }
+ writeFrame(accepted.getOutputStream(), response);
+ }
+ } catch (IOException ex) {
+ if (!closing) {
+ finished.completeExceptionally(ex);
+ }
+ } finally {
+ finished.complete(null);
+ }
+ }
+
+ private static JsonNode readFrame(InputStream input) throws IOException {
+ var header = new StringBuilder();
+ while (!header.toString().endsWith("\r\n\r\n")) {
+ int value = input.read();
+ if (value < 0) {
+ if (header.isEmpty()) {
+ return null;
+ }
+ throw new EOFException("Incomplete frame header");
+ }
+ header.append((char) value);
+ if (header.length() > 1024) {
+ throw new IOException("Frame header too large");
+ }
+ }
+ int length = -1;
+ for (String line : header.toString().split("\r\n")) {
+ if (line.startsWith("Content-Length:")) {
+ var value = line.substring("Content-Length:".length()).trim();
+ try {
+ length = Integer.parseInt(value);
+ } catch (NumberFormatException ex) {
+ throw new IOException("Invalid Content-Length: " + value, ex);
+ }
+ }
+ }
+ if (length < 0 || length > 65536) {
+ throw new IOException("Invalid frame length: " + length);
+ }
+ byte[] body = input.readNBytes(length);
+ if (body.length != length) {
+ throw new EOFException("Incomplete frame body");
+ }
+ return MAPPER.readTree(body);
+ }
+
+ private static void writeFrame(OutputStream output, JsonNode response) throws IOException {
+ byte[] body = MAPPER.writeValueAsBytes(response);
+ output.write(("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
+ output.write(body);
+ output.flush();
+ }
+
+ @Override
+ public void close() throws Exception {
+ closing = true;
+ listener.close();
+ Socket accepted = socket;
+ if (accepted != null) {
+ accepted.close();
+ }
+ worker.join(5000);
+ assertFalse(worker.isAlive(), "Framed peer must terminate");
+ finished.get(5, TimeUnit.SECONDS);
+ }
+ }
+}