fix: address Copilot review round 4 on #2318

- fetchTracking: one non-throwing reporter fronts every stream update —
  the open (inside the fetch wrapper), each coalesced count (from a
  timer) and the close (off the discarded watcher).
- rawWireRequest annotates a request timeout its transport's send
  rejects with, so the browser's relay timeout on a raw-wire call reads
  like every other.
- RemoteClientTransport reports still-open streams closed on close(),
  since the backend watcher's own close cannot reach a channel that the
  disconnect has already torn down.

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 23:20:28 -04:00
commit abc4967298
3 changed files with 180 additions and 15 deletions

View file

@ -310,8 +310,21 @@ function watchLongLivedStream(
framing: LongLivedStreamFraming,
callbacks: FetchTrackingCallbacks,
): void {
const report = callbacks.updateStream;
if (!report) return;
const listener = callbacks.updateStream;
if (!listener) return;
// Every report goes through here: the listener is consumer code (a relay
// sink that may already be closed, a state store), and it runs from three
// places none of which may throw outward — synchronously inside the fetch
// wrapper (the open), from a timer (a coalesced count), and off the
// discarded watcher promise (the close). A throw is the listener's
// problem; the stream's bookkeeping carries on.
const report = (state: FetchStreamState): void => {
try {
listener(id, state);
} catch {
// See above.
}
};
const intervalMs =
callbacks.streamUpdateIntervalMs ?? DEFAULT_STREAM_UPDATE_INTERVAL_MS;
let body: ReadableStream<Uint8Array> | null;
@ -333,19 +346,19 @@ function watchLongLivedStream(
let reportTimer: ReturnType<typeof setTimeout> | null = null;
const reportCount = (): void => {
if (intervalMs <= 0) {
report(id, { eventCount });
report({ eventCount });
return;
}
if (reportTimer !== null) return;
reportTimer = setTimeout(() => {
reportTimer = null;
report(id, { eventCount });
report({ eventCount });
}, intervalMs);
};
// 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
// stream with no state to show — the count only moves on an event.
report(id, { eventCount });
report({ eventCount });
const consumeLine = (line: string): void => {
if (framing === "ndjson") {
@ -410,17 +423,12 @@ function watchLongLivedStream(
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.
}
report({ eventCount, closedAt: new Date() });
};
// 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
// failures — the read loop and the final report are each wrapped in a
// `try` — so the promise cannot reject.
// failures — the read loop is wrapped in a `try`, and every report goes
// through the non-throwing `report` above — so the promise cannot reject.
void pump();
}

View file

@ -2980,7 +2980,17 @@ export class InspectorClient extends InspectorClientEventTarget {
clearTimeout(pending.timer);
this.pendingRawWireRequests.delete(id);
}
reject(err instanceof Error ? err : new Error(String(err)));
// The browser's remote transport awaits the response inside `send`,
// so its relay wait can expire here first, as the SDK's timeout
// shape; annotate it exactly as the local timer above does (a
// non-timeout error passes through untouched).
reject(
annotateRequestTimeout(
err instanceof Error ? err : new Error(String(err)),
method,
this.getConnectionDiagnostics(),
),
);
});
});
return resultSchema.parse(raw);

View file

@ -12,6 +12,7 @@ import type {
MessageExtraInfo,
} from "@modelcontextprotocol/client";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { findHeader, isLongLivedStreamResponse } from "../fetchTracking.js";
import type { InspectorServerSettings, StderrLogEntry } from "../types.js";
import type { FetchRequestEntryBase, FetchStreamState } from "../types.js";
import type {
@ -265,6 +266,14 @@ export class RemoteClientTransport implements Transport {
private eventStreamConsumeTask: Promise<void> | null = null;
private restartingEventStream = false;
private closed = false;
/**
* Long-lived streams the backend has reported open, by fetch entry id, with
* the last event count seen for each. `close()` reports them closed
* itself: it aborts the event channel before asking the backend to
* disconnect, so the backend watcher's own close report lands on an
* unconsumed queue and is lost with the session (#2318).
*/
private openStreams = new Map<string, number>();
private readonly sseResponseWaits = new Map<
string | number,
SseResponseWait
@ -548,6 +557,14 @@ export class RemoteClientTransport implements Transport {
this.options.onFetchRequest
) {
const entry = parsed.data;
if (
isLongLivedStreamResponse(
entry.method,
findHeader(entry.responseHeaders, "content-type"),
)
) {
this.openStreams.set(entry.id, 0);
}
this.options.onFetchRequest({
...entry,
timestamp:
@ -568,6 +585,10 @@ export class RemoteClientTransport implements Transport {
this.options.onFetchStreamUpdate
) {
const { id, eventCount, closedAt } = parsed.data;
if (closedAt !== undefined) this.openStreams.delete(id);
else if (this.openStreams.has(id)) {
this.openStreams.set(id, eventCount);
}
this.options.onFetchStreamUpdate(id, {
eventCount,
...(closedAt !== undefined && { closedAt: new Date(closedAt) }),
@ -856,6 +877,13 @@ export class RemoteClientTransport implements Transport {
this.closed = true;
this.cancelAllSseWaits(new Error("Transport closed"));
await this.stopEventStream();
// The disconnect below ends every stream the backend still holds open,
// and its close reports cannot reach us any more (see `openStreams`).
const closedAt = new Date();
for (const [id, eventCount] of this.openStreams) {
this.options.onFetchStreamUpdate?.(id, { eventCount, closedAt });
}
this.openStreams.clear();
if (this._sessionId) {
try {