fix: address Copilot review round 3 on #2318

- fetchTracking: the close report is guarded so a throwing listener
  cannot become an unhandled rejection off the discarded watcher; a
  bare CR that is the last byte of the stream terminates its event; a
  moving event count is coalesced to one report per interval (default
  250ms, 0 in tests), with the open and the close still immediate.
- MessageTrackingTransport rolls back a request whose signal was already
  aborted before the send began, and suppresses the rollback only for an
  abort that lands during the send.
- useTickingClock takes an `enabled` flag; Connection Info installs no
  interval when there is no activity section.
- The diagnostics-callbacks test justifies its private-seam cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-13 22:53:08 -04:00
commit ec09628705
6 changed files with 221 additions and 25 deletions

View file

@ -337,8 +337,13 @@ export function ConnectionInfoContent({
: SERVER_INFO_NOT_REPORTED_LABEL;
// The activity rows' clock: the snapshot's own on first paint (pure), then
// the wall clock once a second so "sent 5s ago" keeps counting.
const now = useTickingClock(diagnostics?.capturedAt ?? 0);
// the wall clock once a second so "sent 5s ago" keeps counting. No timer
// at all when there is no activity section to tick.
const now = useTickingClock(
diagnostics?.capturedAt ?? 0,
1000,
diagnostics !== undefined,
);
const serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
const clientCaps = getCapabilityEntries(

View file

@ -47,6 +47,28 @@ describe("useTickingClock", () => {
expect(result.current).toBe(5_002_500);
});
it("installs no timer and returns the seed when disabled", () => {
vi.useFakeTimers();
vi.setSystemTime(5_000_000);
const setSpy = vi.spyOn(globalThis, "setInterval");
const { result, rerender } = renderHook(
({ enabled }) => useTickingClock(1_000_000, 1000, enabled),
{ initialProps: { enabled: false } },
);
expect(setSpy).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(result.current).toBe(1_000_000);
// Enabling later starts the clock.
rerender({ enabled: true });
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current).toBe(5_004_000);
setSpy.mockRestore();
});
it("stops ticking on unmount", () => {
vi.useFakeTimers();
const clearSpy = vi.spyOn(globalThis, "clearInterval");

View file

@ -16,12 +16,20 @@ import { useEffect, useState } from "react";
* A timer is exactly the "synchronize with an external system" case an effect
* exists for; the state is set only from the interval callback, never in the
* effect body, so the first paint is not followed by a corrective re-render.
* `enabled: false` installs no timer at all and returns the seed.
*/
export function useTickingClock(seed: number, intervalMs = 1000): number {
export function useTickingClock(
seed: number,
intervalMs = 1000,
enabled = true,
): number {
const [now, setNow] = useState(seed);
useEffect(() => {
// No timer for a consumer with nothing to tick — a component that
// renders no durations must not pay for a repeating interval.
if (!enabled) return;
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
}, [intervalMs, enabled]);
return Math.max(now, seed);
}

View file

@ -748,6 +748,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
{
trackRequest: (entry) => tracked.push(entry),
updateStream: (id, state) => updates.push({ id, stream: state }),
streamUpdateIntervalMs: 0,
},
);
const response = await fetcher("https://example.com/mcp", {
@ -800,7 +801,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
new Response(server.stream, {
headers: { "content-type": "application/x-ndjson" },
})) as typeof fetch,
{ updateStream: (id, state) => updates.push({ id, stream: state }) },
{
updateStream: (id, state) => updates.push({ id, stream: state }),
streamUpdateIntervalMs: 0,
},
);
await fetcher("https://example.com/mcp", { method: "GET" });
server.push('{"jsonrpc":"2.0","method":"a"}\n\n');
@ -846,7 +850,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
new Response(server.stream, {
headers: { "content-type": "Text/Event-Stream" },
})) as typeof fetch,
{ updateStream: (id, state) => updates.push({ id, state }) },
{
updateStream: (id, state) => updates.push({ id, state }),
streamUpdateIntervalMs: 0,
},
);
await fetcher("https://example.com/mcp", { method: "get" });
expect(updates).toHaveLength(1);
@ -867,6 +874,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
trackRequest: (entry) => tracked.push(entry),
updateResponseBody: (_id, body) => bodies.push(body),
updateStream: (id, state) => updates.push({ id, state }),
streamUpdateIntervalMs: 0,
},
);
await fetcher(new Request("https://example.com/mcp", { method: "POST" }));
@ -876,6 +884,80 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
expect(updates).toEqual([]);
});
it("coalesces a moving count to one report per interval, and reports the close at once", async () => {
vi.useFakeTimers();
try {
const server = controlledStream();
const updates: Array<{ id: string; stream: FetchStreamState }> = [];
const fetcher = createFetchTracker(
(async () =>
new Response(server.stream, {
headers: { "content-type": "text/event-stream" },
})) as typeof fetch,
{
updateStream: (id, state) => updates.push({ id, stream: state }),
streamUpdateIntervalMs: 100,
},
);
await fetcher("https://example.com/mcp", { method: "GET" });
// The open is immediate.
expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
server.push("data: a\n\ndata: b\n\ndata: c\n\n");
await vi.advanceTimersByTimeAsync(0);
// Three events, no report yet — they are inside one window.
expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
await vi.advanceTimersByTimeAsync(100);
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3]);
// A close cancels a pending window and reports immediately.
server.push("data: d\n\n");
server.end();
await vi.advanceTimersByTimeAsync(0);
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3, 4]);
expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date);
await vi.advanceTimersByTimeAsync(200);
expect(updates).toHaveLength(3);
} finally {
vi.useRealTimers();
}
});
it("counts an event whose bare-CR terminator is the last byte of the stream", async () => {
const server = controlledStream();
const { updates } = await trackStream(server.stream);
server.push("data: x\r\r");
await flush();
// Held while the stream is open: the CR could be half of a CRLF.
expect(updates.map((u) => u.stream.eventCount)).toEqual([0]);
server.end();
await flush();
expect(updates.at(-1)!.stream).toMatchObject({ eventCount: 1 });
expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date);
});
it("survives a stream-update listener that throws on the close", async () => {
// The watcher's promise is discarded, so a throw from the final report
// would otherwise be an unhandled rejection — which fails the whole run.
const server = controlledStream();
let reports = 0;
const fetcher = createFetchTracker(
(async () =>
new Response(server.stream, {
headers: { "content-type": "text/event-stream" },
})) as typeof fetch,
{
updateStream: (_id, state) => {
reports += 1;
if (state.closedAt) throw new Error("listener exploded");
},
streamUpdateIntervalMs: 0,
},
);
await fetcher("https://example.com/mcp", { method: "GET" });
server.end();
await flush();
expect(reports).toBe(2);
});
it("reports the close when the server ends the stream", async () => {
const server = controlledStream();
const { updates, id } = await trackStream(server.stream);
@ -932,7 +1014,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
new Response("data: x\n\n", {
headers: { "content-type": "text/event-stream" },
})) as typeof fetch,
{ updateStream: (id, state) => updates.push({ id, state }) },
{
updateStream: (id, state) => updates.push({ id, state }),
streamUpdateIntervalMs: 0,
},
);
// A POST SSE reply is bounded and goes down the body-capture path.
await fetcher("https://example.com/mcp", { method: "POST" });
@ -950,6 +1035,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
const updates: unknown[] = [];
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
updateStream: (id, state) => updates.push({ id, state }),
streamUpdateIntervalMs: 0,
});
await expect(
fetcher("https://example.com/mcp", { method: "GET" }),
@ -971,6 +1057,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
const updates: unknown[] = [];
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
updateStream: (id, state) => updates.push({ id, state }),
streamUpdateIntervalMs: 0,
});
await fetcher("https://example.com/mcp", { method: "GET" });
await flush();

View file

@ -23,6 +23,12 @@ describe("InspectorClient diagnostics tracking callbacks", () => {
// Never connects; the callbacks are exercised directly.
{ environment: { transport: () => ({}) as never } },
);
// `createMessageTrackingCallbacks` is private: it is the seam the client
// hands its transport, and driving it directly is what lets this test
// exercise the bookkeeping without a connection. The double cast is the
// documented test-only exception for reaching a private member — the
// same one `inspectorClient-raw-wire.test.ts` uses — and `Internals`
// names exactly the one method it reaches.
const callbacks = (
client as unknown as Internals
).createMessageTrackingCallbacks();

View file

@ -48,18 +48,38 @@ describe("MessageTrackingTransport.send", () => {
await expect(failing.send(request)).rejects.toBe(boom);
expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client");
expect(trackSendFailure).toHaveBeenCalledWith(request, boom);
// A caller's own abort is a cancellation, not a send failure: the
// A signal already aborted before the send began: the frame never went
// out, so this is a failed send and rolls back.
trackSendFailure.mockClear();
const preAborted = new AbortController();
preAborted.abort();
const preAbortedRequest = {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
} as const;
await expect(
failing.send(preAbortedRequest, { requestSignal: preAborted.signal }),
).rejects.toBe(boom);
expect(trackSendFailure).toHaveBeenCalledWith(preAbortedRequest, boom);
// A signal aborted while the send is in flight is a cancellation: the
// request may well have reached the server, and stays unanswered.
trackSendFailure.mockClear();
const controller = new AbortController();
controller.abort();
const midSend = new AbortController();
base.send = async () => {
midSend.abort();
throw boom;
};
await expect(
failing.send(
{ jsonrpc: "2.0", id: 2, method: "tools/call" },
{ requestSignal: controller.signal },
{ jsonrpc: "2.0", id: 3, method: "tools/call" },
{ requestSignal: midSend.signal },
),
).rejects.toBe(boom);
expect(trackSendFailure).not.toHaveBeenCalled();
base.send = async () => {
throw boom;
};
// Only requests roll back: a failed notification has no pending entry.
trackSendFailure.mockClear();
await expect(