feat(core): surface mid-session transport failures via lastError (#1489)
Closes #1323. Adds an `error`-event-driven `lastError` on useInspectorClient and a mid-session failure toast in App.tsx; the transport onerror is scoped to non-handshake (status !== connecting) so handshake failures stay on the connect() rejection path. Follow-up: #1490.
This commit is contained in:
parent
bb32d1db5f
commit
b4716a1405
4 changed files with 398 additions and 7 deletions
|
|
@ -707,6 +707,60 @@ describe("App network-log body-dropped toast", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("App mid-session error toast", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
notificationsMock.show.mockClear();
|
||||
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||
});
|
||||
|
||||
it("toasts the lastError with a generic title when no server is active", () => {
|
||||
// `lastError` is set but nothing has been connected, so the active-server
|
||||
// name ref is empty and the toast falls back to "Connection lost".
|
||||
vi.mocked(useInspectorClient).mockReturnValue({
|
||||
...DEFAULT_USE_INSPECTOR_CLIENT,
|
||||
lastError: "stdio subprocess crashed",
|
||||
});
|
||||
renderWithMantine(<App />);
|
||||
|
||||
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
|
||||
const shown = notificationsMock.show.mock.calls[0][0];
|
||||
expect(shown.title).toBe("Connection lost");
|
||||
expect(shown.message).toBe("stdio subprocess crashed");
|
||||
expect(shown.color).toBe("red");
|
||||
});
|
||||
|
||||
it("names the active server in the toast after a session has connected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
// Connect first so the active-server name ref is populated with SERVER_A.
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
// The transport now dies mid-session: `lastError` becomes set and the
|
||||
// client's `disconnect` event clears the active server. The name ref
|
||||
// survives, so the toast still names the server (PlotRocket).
|
||||
vi.mocked(useInspectorClient).mockReturnValue({
|
||||
...DEFAULT_USE_INSPECTOR_CLIENT,
|
||||
lastError: "SSE stream dropped",
|
||||
});
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(new CustomEvent("disconnect"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(notificationsMock.show).toHaveBeenCalled());
|
||||
const shown = notificationsMock.show.mock.calls.at(-1)?.[0];
|
||||
expect(shown.title).toBe('Connection to "PlotRocket" lost');
|
||||
expect(shown.message).toBe("SSE stream dropped");
|
||||
expect(shown.color).toBe("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("App pending server-initiated request modal", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
|
|
|
|||
|
|
@ -666,6 +666,7 @@ function App() {
|
|||
serverInfo,
|
||||
instructions,
|
||||
protocolVersion,
|
||||
lastError,
|
||||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
tools,
|
||||
|
|
@ -1085,6 +1086,30 @@ function App() {
|
|||
[servers, activeServerId],
|
||||
);
|
||||
|
||||
// Mirror the active server's name into a ref so a mid-session failure toast
|
||||
// can still name the server: a transport crash dispatches `disconnect`,
|
||||
// which clears `activeServerId` (and thus `activeServer`) before the
|
||||
// `lastError` effect below runs, so the ref is the only surviving handle.
|
||||
const activeServerNameRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (activeServer) activeServerNameRef.current = activeServer.name;
|
||||
}, [activeServer]);
|
||||
|
||||
// Surface a mid-session transport failure (stdio crash, SSE drop, HTTP 5xx)
|
||||
// as a toast. The handshake case is handled in `onToggleConnection`'s catch;
|
||||
// this covers the `status: connected → error` transition that fires the
|
||||
// client's `error` event without rejecting any awaited promise (#1323).
|
||||
// `lastError` clears at the next connecting edge, so each failure toasts once.
|
||||
useEffect(() => {
|
||||
if (!lastError) return;
|
||||
const name = activeServerNameRef.current;
|
||||
notifications.show({
|
||||
title: name ? `Connection to "${name}" lost` : "Connection lost",
|
||||
message: lastError,
|
||||
color: "red",
|
||||
});
|
||||
}, [lastError]);
|
||||
|
||||
// `config.type` is optional in the schema (a bare `command: ...`
|
||||
// entry implies stdio), so we materialize the default here rather
|
||||
// than at the render site — the modal's `transport` prop is a
|
||||
|
|
@ -1470,9 +1495,10 @@ function App() {
|
|||
// same behavior by reading from `serverSettings` on the client.
|
||||
await client.connect();
|
||||
} catch (err) {
|
||||
// Handshake-only. A mid-session transport failure does not throw,
|
||||
// so a future error event from InspectorClient is the right hook
|
||||
// for surfacing those (TODO(#1323)).
|
||||
// Handshake-only. A mid-session transport failure does not throw; the
|
||||
// client's `error` event surfaces those, consumed via
|
||||
// `useInspectorClient`'s `lastError` and toasted in the effect above
|
||||
// (#1323).
|
||||
connectStartRef.current = undefined;
|
||||
|
||||
// A 401 from an OAuth-protected server means we have no (valid) token
|
||||
|
|
|
|||
|
|
@ -150,6 +150,98 @@ describe("useInspectorClient", () => {
|
|||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("captures lastError from the client's error event", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
expect(result.current.lastError).toBeUndefined();
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("error", new Error("stdio subprocess crashed"));
|
||||
});
|
||||
expect(result.current.lastError).toBe("stdio subprocess crashed");
|
||||
});
|
||||
|
||||
it("clears lastError when a new connection attempt begins (connecting)", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("error", new Error("SSE stream dropped"));
|
||||
});
|
||||
expect(result.current.lastError).toBe("SSE stream dropped");
|
||||
|
||||
// A reconnect drives status → "connecting", which clears the stale error.
|
||||
act(() => {
|
||||
client.setStatus("connecting");
|
||||
});
|
||||
expect(result.current.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps lastError across the error status transition", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
// The transport onerror path fires statusChange("error") then error; the
|
||||
// "error" status must not clear the message the error event carries.
|
||||
act(() => {
|
||||
client.setStatus("error");
|
||||
client.dispatchTypedEvent("error", new Error("HTTP 503"));
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.lastError).toBe("HTTP 503");
|
||||
});
|
||||
|
||||
it("keeps lastError across a trailing disconnected transition", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("error", new Error("stdio crashed"));
|
||||
});
|
||||
expect(result.current.lastError).toBe("stdio crashed");
|
||||
|
||||
// A real crash often trails the error with an onclose → statusChange
|
||||
// ("disconnected"). The toast effect depends on that NOT clearing
|
||||
// lastError (only the next "connecting" edge does).
|
||||
act(() => {
|
||||
client.setStatus("disconnected");
|
||||
});
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
expect(result.current.lastError).toBe("stdio crashed");
|
||||
});
|
||||
|
||||
it("resets lastError when the client prop changes", () => {
|
||||
const a = new FakeInspectorClient({ status: "connected" });
|
||||
const b = new FakeInspectorClient({ status: "connected" });
|
||||
const { result, rerender } = renderHook(
|
||||
({ c }: { c: InspectorClientProtocol }) => useInspectorClient(c),
|
||||
{ initialProps: { c: a as InspectorClientProtocol } },
|
||||
);
|
||||
act(() => {
|
||||
a.dispatchTypedEvent("error", new Error("boom"));
|
||||
});
|
||||
expect(result.current.lastError).toBe("boom");
|
||||
|
||||
rerender({ c: b });
|
||||
expect(result.current.lastError).toBeUndefined();
|
||||
|
||||
// The old client's error event is ignored after the swap.
|
||||
act(() => {
|
||||
a.dispatchTypedEvent("error", new Error("late"));
|
||||
});
|
||||
expect(result.current.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resets lastError when client becomes null", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result, rerender } = renderHook(
|
||||
({ c }: { c: InspectorClientProtocol | null }) => useInspectorClient(c),
|
||||
{ initialProps: { c: client as InspectorClientProtocol | null } },
|
||||
);
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("error", new Error("boom"));
|
||||
});
|
||||
expect(result.current.lastError).toBe("boom");
|
||||
rerender({ c: null });
|
||||
expect(result.current.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads clientCapabilities lazily from the client and defaults to {} when null", () => {
|
||||
const advertised: ClientCapabilities = {
|
||||
elicitation: { form: {} },
|
||||
|
|
|
|||
|
|
@ -2527,6 +2527,175 @@ describe("InspectorClient", () => {
|
|||
await client.disconnect();
|
||||
expect(disconnectFired).toBe(true);
|
||||
});
|
||||
|
||||
it("emits an error event and sets status to error on a mid-session transport failure", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
},
|
||||
);
|
||||
|
||||
const errors: Error[] = [];
|
||||
client.addEventListener("error", (event) => {
|
||||
errors.push(event.detail);
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
// Simulate the transport dying mid-session (stdio crash / SSE drop /
|
||||
// HTTP 5xx) by invoking the `onerror` the client attached to the base
|
||||
// transport — the same callback the SDK fires on a real failure.
|
||||
const baseTransport = (
|
||||
client as unknown as {
|
||||
baseTransport: { onerror?: (error: Error) => void };
|
||||
}
|
||||
).baseTransport;
|
||||
const failure = new Error("stdio subprocess crashed");
|
||||
baseTransport.onerror?.(failure);
|
||||
|
||||
expect(client.getStatus()).toBe("error");
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBe(failure);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("still emits error when onclose lands before onerror on a mid-session crash", async () => {
|
||||
// Many transports fire BOTH onclose and onerror on a real crash, in a
|
||||
// transport-dependent order. When onclose lands first it flips status to
|
||||
// "disconnected"; the guard must NOT swallow the trailing onerror's
|
||||
// reason (its only surface). Regression lock for the #1489 re-review.
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
},
|
||||
);
|
||||
|
||||
const errors: Error[] = [];
|
||||
client.addEventListener("error", (event) => {
|
||||
errors.push(event.detail);
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
const baseTransport = (
|
||||
client as unknown as {
|
||||
baseTransport: {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
).baseTransport;
|
||||
|
||||
// onclose first → status "disconnected"; then the trailing onerror.
|
||||
baseTransport.onclose?.();
|
||||
expect(client.getStatus()).toBe("disconnected");
|
||||
const failure = new Error("stdio subprocess crashed");
|
||||
baseTransport.onerror?.(failure);
|
||||
|
||||
expect(client.getStatus()).toBe("error");
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBe(failure);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("does not emit an error event when the handshake rejects connect()", async () => {
|
||||
// A transport whose start() rejects makes connect() reject (handshake
|
||||
// failure). The caller gets the reason via the rejected promise, so the
|
||||
// client must NOT also dispatch the `error` event (which is reserved for
|
||||
// non-awaited, mid-session failures). See #1323.
|
||||
const failingFactory = () => ({
|
||||
transport: {
|
||||
start: () => Promise.reject(new Error("handshake refused")),
|
||||
send: async () => {},
|
||||
close: async () => {},
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
onmessage: undefined,
|
||||
sessionId: undefined,
|
||||
} as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "http://localhost:1/never" },
|
||||
{ environment: { transport: failingFactory } },
|
||||
);
|
||||
|
||||
let errorFired = false;
|
||||
client.addEventListener("error", () => {
|
||||
errorFired = true;
|
||||
});
|
||||
|
||||
await expect(client.connect()).rejects.toThrow(/handshake refused/);
|
||||
expect(client.getStatus()).toBe("error");
|
||||
expect(errorFired).toBe(false);
|
||||
});
|
||||
|
||||
it("does not emit error or flip to error when onerror fires during the handshake", async () => {
|
||||
// The transport listeners are attached before the handshake runs, so a
|
||||
// transport that reports a connect-time error via `onerror` (some SDK
|
||||
// transports do this in addition to rejecting connect()) must NOT be
|
||||
// treated as a mid-session failure: that would double-report a handshake
|
||||
// error the awaited connect() rejection already surfaces. See #1323.
|
||||
let rejectStart: (error: Error) => void = () => {};
|
||||
const startPromise = new Promise<void>((_resolve, reject) => {
|
||||
rejectStart = reject;
|
||||
});
|
||||
const transport = {
|
||||
start: () => startPromise,
|
||||
send: async () => {},
|
||||
close: async () => {},
|
||||
onclose: undefined as undefined | (() => void),
|
||||
onerror: undefined as undefined | ((error: Error) => void),
|
||||
onmessage: undefined,
|
||||
sessionId: undefined,
|
||||
};
|
||||
const factory = () => ({
|
||||
transport:
|
||||
transport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "http://localhost:1/never" },
|
||||
{ environment: { transport: factory } },
|
||||
);
|
||||
|
||||
let errorFired = false;
|
||||
client.addEventListener("error", () => {
|
||||
errorFired = true;
|
||||
});
|
||||
|
||||
// Kick off connect() without awaiting — it parks on the hanging start().
|
||||
// `status` is "connecting" and `onerror` is wired by this point.
|
||||
const pending = client.connect().catch(() => {});
|
||||
expect(client.getStatus()).toBe("connecting");
|
||||
|
||||
// A connect-time transport error arrives via onerror.
|
||||
transport.onerror?.(new Error("socket error mid-handshake"));
|
||||
|
||||
// It must be ignored: still connecting, no error event dispatched.
|
||||
expect(client.getStatus()).toBe("connecting");
|
||||
expect(errorFired).toBe(false);
|
||||
|
||||
// Unblock the handshake so connect() settles (and tears down cleanly).
|
||||
rejectStart(new Error("aborted"));
|
||||
await pending;
|
||||
// connect()'s catch transitions to "error" via the rejection path —
|
||||
// still without dispatching the `error` event.
|
||||
expect(client.getStatus()).toBe("error");
|
||||
expect(errorFired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sampling Requests", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue