fix: cancellable compat probes and a validated budget (#2319 round 16)
Two real bugs and two stale rationales.
The OIDC fallback probes were issued with `{ headers }` and no signal,
so a caller that aborted after the initial 404 got a timeout reported
in place of its own cancellation, while the probe ran on to its own
deadline. The probes now carry the caller's signal — via the shared
`callerSignalOf`, so the precedence rule has one definition — a
pre-aborted caller is not probed at all, and an abort is rethrown
rather than folded back into the preceding response.
`Math.max(0, Math.round(timeoutMs))` let NaN and Infinity through.
`setTimeout(fn, NaN)` fires immediately, and a delay above 2**31-1
overflows a 32-bit signed int and falls back to 1ms — so a caller
asking for a very large budget, or one whose arithmetic produced NaN,
got an immediate OAuth timeout. `normalizeBudget` falls back to the
default on non-finite and clamps to the timer's range; the route's
envelope-supplied deadline gets the same clamp, but ignores a
non-finite value outright since a malformed deadline reads safest as
no deadline.
The wire-marker docblock and the matching route test still said a
backend deadline is the only one on the transport-internal discovery
path, which round 11 changed. Both now give the real reason the marker
matters: both ends run the same budget, and it is the backend winning
the race — a backgrounded tab throttling setTimeout — that must not
decide whether the error names its endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
93965936ba
commit
e234343702
2 changed files with 185 additions and 13 deletions
|
|
@ -90,7 +90,7 @@
|
|||
* wiring in `core/mcp/inspectorClient.ts`.
|
||||
*/
|
||||
|
||||
import { OAuthRequestTimeoutError } from "./requestTimeout.js";
|
||||
import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js";
|
||||
import {
|
||||
OAuthMetadataSchema,
|
||||
OpenIdProviderDiscoveryMetadataSchema,
|
||||
|
|
@ -249,6 +249,13 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
if (candidates.length === 0) return response;
|
||||
|
||||
const headers = discoveryHeaders(input, init);
|
||||
// The probe is a request the caller never made, issued on its behalf — so
|
||||
// it has to be cancellable by the caller too. Without the signal, aborting
|
||||
// after the initial 404 left the probe running to its own deadline and the
|
||||
// caller was handed a timeout in place of its own abort (Copilot). Same
|
||||
// precedence rule as everywhere else, hence the shared helper rather than a
|
||||
// second copy of it.
|
||||
const callerSignal = callerSignalOf(input, init);
|
||||
// The loop advances to the next candidate only where the SDK's own loop
|
||||
// would. Anywhere else it hands the original response back and lets
|
||||
// discovery run its normal course, because *promoting a later candidate
|
||||
|
|
@ -260,9 +267,15 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
// those, returning `response` leaves the SDK to make the same request and
|
||||
// reach the same verdict it always would.
|
||||
for (const candidate of candidates) {
|
||||
// Don't issue a probe the caller has already given up on. A real `fetch`
|
||||
// rejects immediately on a pre-aborted signal, so this is mostly about
|
||||
// not sending the request at all — the same reasoning as the
|
||||
// short-circuit in `withOAuthRequestTimeout`.
|
||||
if (callerSignal?.aborted) throw callerSignal.reason;
|
||||
|
||||
let probe: Response;
|
||||
try {
|
||||
probe = await fetchFn(candidate, { headers });
|
||||
probe = await fetchFn(candidate, { headers, signal: callerSignal });
|
||||
} catch (err) {
|
||||
// A deadline the Inspector imposed is *our* error, not the server's
|
||||
// answer, so it escapes rather than being folded back into the original
|
||||
|
|
@ -275,6 +288,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
// stall the SDK would have hit itself, unbounded. Everything else — a
|
||||
// CORS rejection, DNS, a reset — still falls back as documented above.
|
||||
if (err instanceof OAuthRequestTimeoutError) throw err;
|
||||
// Likewise a caller that gave up: substituting the preceding 404 for
|
||||
// its own abort would report a discovery failure for a request it
|
||||
// deliberately cancelled.
|
||||
if (callerSignal?.aborted) throw err;
|
||||
return response;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,33 @@ export class OAuthRequestTimeoutError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the
|
||||
* delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an
|
||||
* `Infinity` or an over-large budget produces an *immediate* timeout — the exact
|
||||
* opposite of what the caller asked for (Copilot).
|
||||
*/
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Coerce a caller's budget into something `setTimeout` can honour and the error
|
||||
* can honestly report.
|
||||
*
|
||||
* - Non-finite (`NaN`, `±Infinity`) falls back to the default. `NaN` survives
|
||||
* `Math.max(0, Math.round(...))` and `setTimeout(fn, NaN)` fires immediately,
|
||||
* so a caller whose arithmetic produced a `NaN` would otherwise see every
|
||||
* OAuth request fail at once with "timed out after NaNms".
|
||||
* - Above `MAX_TIMER_DELAY_MS` it clamps rather than overflowing to 1ms.
|
||||
* - Negative clamps to 0, which is a real budget (fire on the next tick), not
|
||||
* an error.
|
||||
*
|
||||
* Rounded because the budget is *reported* — see the note at the call site.
|
||||
*/
|
||||
function normalizeBudget(timeoutMs: number): number {
|
||||
if (!Number.isFinite(timeoutMs)) return DEFAULT_OAUTH_REQUEST_TIMEOUT_MS;
|
||||
return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, Math.round(timeoutMs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How a proxy-side deadline is carried back to the browser (#2319).
|
||||
*
|
||||
|
|
@ -90,10 +117,12 @@ export class OAuthRequestTimeoutError extends Error {
|
|||
* `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a
|
||||
* timeout enforced by the *backend* would arrive as an untyped error and the
|
||||
* `instanceof OAuthRequestTimeoutError` checks downstream would all be false
|
||||
* (Copilot). That matters most exactly where there is no client-side wrapper to
|
||||
* have fired first: the discovery the SDK runs from inside the transport. So
|
||||
* the route stamps this marker and `createRemoteFetch` reconstructs the typed
|
||||
* error from it.
|
||||
* (Copilot). Both ends run the same budget, so the client's race usually
|
||||
* settles first; this matters when the backend's timer wins anyway — most
|
||||
* plausibly a backgrounded tab, where the browser throttles `setTimeout` while
|
||||
* the server's fires on schedule. Which of two equal deadlines happened to fire
|
||||
* must not decide whether the error names its endpoint. So the route stamps
|
||||
* this marker and `createRemoteFetch` reconstructs the typed error from it.
|
||||
*/
|
||||
export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout";
|
||||
|
||||
|
|
@ -204,7 +233,7 @@ function requestUrlOf(input: RequestInfo | URL): string {
|
|||
* `Request`'s signal rather than clear it (Copilot). An explicit `null` is the
|
||||
* genuine "no signal" override and is honoured as one.
|
||||
*/
|
||||
function callerSignalOf(
|
||||
export function callerSignalOf(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
): AbortSignal | undefined {
|
||||
|
|
@ -324,7 +353,7 @@ export function withOAuthRequestTimeout(
|
|||
// public field. Rounding rather than flooring so a caller's own
|
||||
// whole-millisecond timeout survives the trip through that clock and is still
|
||||
// the number the message names.
|
||||
const budget = Math.max(0, Math.round(timeoutMs));
|
||||
const budget = normalizeBudget(timeoutMs);
|
||||
|
||||
return async (input, init) => {
|
||||
const url = requestUrlOf(input);
|
||||
|
|
|
|||
Loading…
Reference in a new issue