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:
parent
1b46b9564b
commit
ec09628705
8 changed files with 221 additions and 25 deletions
|
|
@ -337,8 +337,13 @@ export function ConnectionInfoContent({
|
||||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||||
|
|
||||||
// The activity rows' clock: the snapshot's own on first paint (pure), then
|
// 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.
|
// the wall clock once a second so "sent 5s ago" keeps counting. No timer
|
||||||
const now = useTickingClock(diagnostics?.capturedAt ?? 0);
|
// 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 serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
|
||||||
const clientCaps = getCapabilityEntries(
|
const clientCaps = getCapabilityEntries(
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,28 @@ describe("useTickingClock", () => {
|
||||||
expect(result.current).toBe(5_002_500);
|
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", () => {
|
it("stops ticking on unmount", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const clearSpy = vi.spyOn(globalThis, "clearInterval");
|
const clearSpy = vi.spyOn(globalThis, "clearInterval");
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,20 @@ import { useEffect, useState } from "react";
|
||||||
* A timer is exactly the "synchronize with an external system" case an effect
|
* 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
|
* 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.
|
* 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);
|
const [now, setNow] = useState(seed);
|
||||||
useEffect(() => {
|
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);
|
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [intervalMs]);
|
}, [intervalMs, enabled]);
|
||||||
return Math.max(now, seed);
|
return Math.max(now, seed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
{
|
{
|
||||||
trackRequest: (entry) => tracked.push(entry),
|
trackRequest: (entry) => tracked.push(entry),
|
||||||
updateStream: (id, state) => updates.push({ id, stream: state }),
|
updateStream: (id, state) => updates.push({ id, stream: state }),
|
||||||
|
streamUpdateIntervalMs: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const response = await fetcher("https://example.com/mcp", {
|
const response = await fetcher("https://example.com/mcp", {
|
||||||
|
|
@ -800,7 +801,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
new Response(server.stream, {
|
new Response(server.stream, {
|
||||||
headers: { "content-type": "application/x-ndjson" },
|
headers: { "content-type": "application/x-ndjson" },
|
||||||
})) as typeof fetch,
|
})) 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" });
|
await fetcher("https://example.com/mcp", { method: "GET" });
|
||||||
server.push('{"jsonrpc":"2.0","method":"a"}\n\n');
|
server.push('{"jsonrpc":"2.0","method":"a"}\n\n');
|
||||||
|
|
@ -846,7 +850,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
new Response(server.stream, {
|
new Response(server.stream, {
|
||||||
headers: { "content-type": "Text/Event-Stream" },
|
headers: { "content-type": "Text/Event-Stream" },
|
||||||
})) as typeof fetch,
|
})) 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" });
|
await fetcher("https://example.com/mcp", { method: "get" });
|
||||||
expect(updates).toHaveLength(1);
|
expect(updates).toHaveLength(1);
|
||||||
|
|
@ -867,6 +874,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
trackRequest: (entry) => tracked.push(entry),
|
trackRequest: (entry) => tracked.push(entry),
|
||||||
updateResponseBody: (_id, body) => bodies.push(body),
|
updateResponseBody: (_id, body) => bodies.push(body),
|
||||||
updateStream: (id, state) => updates.push({ id, state }),
|
updateStream: (id, state) => updates.push({ id, state }),
|
||||||
|
streamUpdateIntervalMs: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await fetcher(new Request("https://example.com/mcp", { method: "POST" }));
|
await fetcher(new Request("https://example.com/mcp", { method: "POST" }));
|
||||||
|
|
@ -876,6 +884,80 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
expect(updates).toEqual([]);
|
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 () => {
|
it("reports the close when the server ends the stream", async () => {
|
||||||
const server = controlledStream();
|
const server = controlledStream();
|
||||||
const { updates, id } = await trackStream(server.stream);
|
const { updates, id } = await trackStream(server.stream);
|
||||||
|
|
@ -932,7 +1014,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
new Response("data: x\n\n", {
|
new Response("data: x\n\n", {
|
||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
})) as typeof fetch,
|
})) 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.
|
// A POST SSE reply is bounded and goes down the body-capture path.
|
||||||
await fetcher("https://example.com/mcp", { method: "POST" });
|
await fetcher("https://example.com/mcp", { method: "POST" });
|
||||||
|
|
@ -950,6 +1035,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
const updates: unknown[] = [];
|
const updates: unknown[] = [];
|
||||||
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
|
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
|
||||||
updateStream: (id, state) => updates.push({ id, state }),
|
updateStream: (id, state) => updates.push({ id, state }),
|
||||||
|
streamUpdateIntervalMs: 0,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
fetcher("https://example.com/mcp", { method: "GET" }),
|
fetcher("https://example.com/mcp", { method: "GET" }),
|
||||||
|
|
@ -971,6 +1057,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
|
||||||
const updates: unknown[] = [];
|
const updates: unknown[] = [];
|
||||||
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
|
const fetcher = createFetchTracker((async () => response) 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" });
|
await fetcher("https://example.com/mcp", { method: "GET" });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ describe("InspectorClient diagnostics tracking callbacks", () => {
|
||||||
// Never connects; the callbacks are exercised directly.
|
// Never connects; the callbacks are exercised directly.
|
||||||
{ environment: { transport: () => ({}) as never } },
|
{ 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 = (
|
const callbacks = (
|
||||||
client as unknown as Internals
|
client as unknown as Internals
|
||||||
).createMessageTrackingCallbacks();
|
).createMessageTrackingCallbacks();
|
||||||
|
|
|
||||||
|
|
@ -48,18 +48,38 @@ describe("MessageTrackingTransport.send", () => {
|
||||||
await expect(failing.send(request)).rejects.toBe(boom);
|
await expect(failing.send(request)).rejects.toBe(boom);
|
||||||
expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client");
|
expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client");
|
||||||
expect(trackSendFailure).toHaveBeenCalledWith(request, boom);
|
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.
|
// request may well have reached the server, and stays unanswered.
|
||||||
trackSendFailure.mockClear();
|
trackSendFailure.mockClear();
|
||||||
const controller = new AbortController();
|
const midSend = new AbortController();
|
||||||
controller.abort();
|
base.send = async () => {
|
||||||
|
midSend.abort();
|
||||||
|
throw boom;
|
||||||
|
};
|
||||||
await expect(
|
await expect(
|
||||||
failing.send(
|
failing.send(
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call" },
|
{ jsonrpc: "2.0", id: 3, method: "tools/call" },
|
||||||
{ requestSignal: controller.signal },
|
{ requestSignal: midSend.signal },
|
||||||
),
|
),
|
||||||
).rejects.toBe(boom);
|
).rejects.toBe(boom);
|
||||||
expect(trackSendFailure).not.toHaveBeenCalled();
|
expect(trackSendFailure).not.toHaveBeenCalled();
|
||||||
|
base.send = async () => {
|
||||||
|
throw boom;
|
||||||
|
};
|
||||||
// Only requests roll back: a failed notification has no pending entry.
|
// Only requests roll back: a failed notification has no pending entry.
|
||||||
trackSendFailure.mockClear();
|
trackSendFailure.mockClear();
|
||||||
await expect(
|
await expect(
|
||||||
|
|
|
||||||
|
|
@ -250,8 +250,21 @@ export interface FetchTrackingCallbacks {
|
||||||
* server serializes requests behind it (#2318).
|
* server serializes requests behind it (#2318).
|
||||||
*/
|
*/
|
||||||
updateStream?: (id: string, stream: FetchStreamState) => void;
|
updateStream?: (id: string, stream: FetchStreamState) => void;
|
||||||
|
/**
|
||||||
|
* How often, at most, `updateStream` reports a moving event count, in ms.
|
||||||
|
* The first report (open, zero events) and the close are immediate; counts
|
||||||
|
* in between are coalesced so a server pushing hundreds of notifications a
|
||||||
|
* second costs one update per window rather than one per event — each
|
||||||
|
* update re-publishes the whole fetch log and, on the web client, crosses
|
||||||
|
* the backend-to-browser event stream. Defaults to
|
||||||
|
* `DEFAULT_STREAM_UPDATE_INTERVAL_MS`; `0` reports every event, for tests.
|
||||||
|
*/
|
||||||
|
streamUpdateIntervalMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See `FetchTrackingCallbacks.streamUpdateIntervalMs`. */
|
||||||
|
export const DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The framing a long-lived stream's events arrive in — the two content types
|
* The framing a long-lived stream's events arrive in — the two content types
|
||||||
* `isLongLivedStreamResponse` accepts. SSE dispatches on a blank line;
|
* `isLongLivedStreamResponse` accepts. SSE dispatches on a blank line;
|
||||||
|
|
@ -297,8 +310,10 @@ function watchLongLivedStream(
|
||||||
framing: LongLivedStreamFraming,
|
framing: LongLivedStreamFraming,
|
||||||
callbacks: FetchTrackingCallbacks,
|
callbacks: FetchTrackingCallbacks,
|
||||||
): void {
|
): void {
|
||||||
const update = callbacks.updateStream;
|
const report = callbacks.updateStream;
|
||||||
if (!update) return;
|
if (!report) return;
|
||||||
|
const intervalMs =
|
||||||
|
callbacks.streamUpdateIntervalMs ?? DEFAULT_STREAM_UPDATE_INTERVAL_MS;
|
||||||
let body: ReadableStream<Uint8Array> | null;
|
let body: ReadableStream<Uint8Array> | null;
|
||||||
try {
|
try {
|
||||||
body = response.clone().body;
|
body = response.clone().body;
|
||||||
|
|
@ -313,23 +328,37 @@ function watchLongLivedStream(
|
||||||
let eventCount = 0;
|
let eventCount = 0;
|
||||||
let pending = "";
|
let pending = "";
|
||||||
let blockHasData = false;
|
let blockHasData = false;
|
||||||
|
// A moving count is reported at most once per interval (see
|
||||||
|
// `streamUpdateIntervalMs`); the open and the close go out at once.
|
||||||
|
let reportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const reportCount = (): void => {
|
||||||
|
if (intervalMs <= 0) {
|
||||||
|
report(id, { eventCount });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reportTimer !== null) return;
|
||||||
|
reportTimer = setTimeout(() => {
|
||||||
|
reportTimer = null;
|
||||||
|
report(id, { eventCount });
|
||||||
|
}, intervalMs);
|
||||||
|
};
|
||||||
// Announce the stream as open before a byte arrives. A stream that never
|
// Announce the stream as open before a byte arrives. A stream that never
|
||||||
// delivers anything is the #2187 tell, and it would otherwise be the one
|
// delivers anything is the #2187 tell, and it would otherwise be the one
|
||||||
// stream with no state to show — the count only moves on an event.
|
// stream with no state to show — the count only moves on an event.
|
||||||
update(id, { eventCount });
|
report(id, { eventCount });
|
||||||
|
|
||||||
const consumeLine = (line: string): void => {
|
const consumeLine = (line: string): void => {
|
||||||
if (framing === "ndjson") {
|
if (framing === "ndjson") {
|
||||||
if (line.trim() === "") return;
|
if (line.trim() === "") return;
|
||||||
eventCount += 1;
|
eventCount += 1;
|
||||||
update(id, { eventCount });
|
reportCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (line === "") {
|
if (line === "") {
|
||||||
if (blockHasData) {
|
if (blockHasData) {
|
||||||
eventCount += 1;
|
eventCount += 1;
|
||||||
blockHasData = false;
|
blockHasData = false;
|
||||||
update(id, { eventCount });
|
reportCount();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -368,17 +397,30 @@ function watchLongLivedStream(
|
||||||
pending += decoder.decode(value, { stream: true });
|
pending += decoder.decode(value, { stream: true });
|
||||||
consumeCompleteLines();
|
consumeCompleteLines();
|
||||||
}
|
}
|
||||||
|
// A CR held back as a possible half of a CRLF is, at end-of-stream, a
|
||||||
|
// whole line ending — `data: x\r\r` is a complete event. Anything after
|
||||||
|
// it is a partial block and is dropped, as above.
|
||||||
|
if (pending.endsWith("\r")) consumeLine(pending.slice(0, -1));
|
||||||
} catch {
|
} catch {
|
||||||
// An errored or aborted stream has still ended — fall through to report
|
// An errored or aborted stream has still ended — fall through to report
|
||||||
// the close; the entry's `error` field is for a fetch that never got a
|
// the close; the entry's `error` field is for a fetch that never got a
|
||||||
// response, which this one did.
|
// response, which this one did.
|
||||||
}
|
}
|
||||||
update(id, { eventCount, closedAt: new Date() });
|
if (reportTimer !== null) {
|
||||||
|
clearTimeout(reportTimer);
|
||||||
|
reportTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
report(id, { eventCount, closedAt: new Date() });
|
||||||
|
} catch {
|
||||||
|
// A listener that throws must not become an unhandled rejection off a
|
||||||
|
// promise nothing awaits; the stream is closed either way.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Deliberately not awaited: the fetch wrapper has to return the response
|
// Deliberately not awaited: the fetch wrapper has to return the response
|
||||||
// now, and this watcher lives as long as the stream does. It owns its own
|
// now, and this watcher lives as long as the stream does. It owns its own
|
||||||
// failures — the read loop is wrapped in a `try` that treats an error as a
|
// failures — the read loop and the final report are each wrapped in a
|
||||||
// close — so the promise cannot reject.
|
// `try` — so the promise cannot reject.
|
||||||
void pump();
|
void pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,11 @@ export interface MessageTrackingCallbacks {
|
||||||
* `trackRequest` already recorded it, so a consumer keeping "requests still
|
* `trackRequest` already recorded it, so a consumer keeping "requests still
|
||||||
* awaiting a response" can roll that entry back — a request that never went
|
* awaiting a response" can roll that entry back — a request that never went
|
||||||
* out is not unanswered (#2318). Not fired when the caller aborted the
|
* out is not unanswered (#2318). Not fired when the caller aborted the
|
||||||
* request through `requestSignal`: the modern transport aborts an in-flight
|
* request through `requestSignal` *during* the send: the modern transport
|
||||||
* request's own stream to cancel it (and the SDK does the same on a
|
* aborts an in-flight request's own stream to cancel it (and the SDK does
|
||||||
* per-request timeout), and a request that was sent and then given up on
|
* the same on a per-request timeout), and a request that was sent and then
|
||||||
* is exactly the unanswered kind.
|
* given up on is exactly the unanswered kind. A signal already aborted
|
||||||
|
* before the send began is a failed send like any other.
|
||||||
*/
|
*/
|
||||||
trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void;
|
trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +110,13 @@ export class MessageTrackingTransport implements Transport {
|
||||||
} else if ("method" in message) {
|
} else if ("method" in message) {
|
||||||
const request = message as JSONRPCRequest;
|
const request = message as JSONRPCRequest;
|
||||||
this.callbacks.trackRequest?.(request, "client");
|
this.callbacks.trackRequest?.(request, "client");
|
||||||
|
// A signal already aborted here means the frame will not go out at
|
||||||
|
// all — that is a failed send. One that becomes aborted while the
|
||||||
|
// send is in flight is a cancellation of a request that may well
|
||||||
|
// have reached the server, which stays unanswered.
|
||||||
|
const abortedBeforeSend = options?.requestSignal?.aborted === true;
|
||||||
return this.baseTransport.send(message, options).catch((err) => {
|
return this.baseTransport.send(message, options).catch((err) => {
|
||||||
if (!options?.requestSignal?.aborted) {
|
if (abortedBeforeSend || !options?.requestSignal?.aborted) {
|
||||||
this.callbacks.trackSendFailure?.(request, err);
|
this.callbacks.trackSendFailure?.(request, err);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue