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:
cliffhall 2026-09-11 06:06:36 -04:00
commit e234343702
3 changed files with 185 additions and 13 deletions

View file

@ -439,3 +439,82 @@ describe("withRfc8414OidcCompat", () => {
expect(base).toHaveBeenCalledTimes(2);
});
});
describe("probe cancellation (#2319)", () => {
const RFC8414 =
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
/** 404 on the RFC 8414 leg, then never answers the OIDC probe. */
function stallingProbeFetch() {
return vi.fn<typeof fetch>((input, init) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return new Promise<Response>((_resolve, reject) => {
// A real `fetch` rejects immediately on a signal that is already
// aborted, so the double is only useful if it does the same.
if (init?.signal?.aborted) {
reject(init.signal.reason as Error);
return;
}
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason as Error),
);
});
});
}
it("carries the caller's signal into the probe", async () => {
const inner = stallingProbeFetch();
const wrapped = withRfc8414OidcCompat(inner);
const caller = new AbortController();
const settled = wrapped(RFC8414, { signal: caller.signal }).then(
(r) => r,
(e: unknown) => e,
);
// Wait until the probe is actually in flight, so the abort exercises
// cancellation rather than the pre-aborted short-circuit.
await vi.waitFor(() => {
expect(inner).toHaveBeenCalledTimes(2);
});
const reason = new Error("caller gave up");
caller.abort(reason);
// Not the preceding 404: substituting it would report a discovery failure
// for a request the caller deliberately cancelled.
expect(await settled).toBe(reason);
expect((inner.mock.calls[1][1] as RequestInit).signal).toBe(caller.signal);
});
it("does not issue a probe the caller already cancelled", async () => {
const inner = stallingProbeFetch();
const wrapped = withRfc8414OidcCompat(inner);
const reason = new Error("already gone");
const settled = wrapped(RFC8414, {
signal: AbortSignal.abort(reason),
}).then(
(r) => r,
(e: unknown) => e,
);
expect(await settled).toBe(reason);
// Only the RFC 8414 leg, which the caller did make.
expect(inner).toHaveBeenCalledTimes(1);
});
it("still falls back to the original response on an ordinary probe failure", async () => {
const inner = vi.fn<typeof fetch>((input) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return Promise.reject(new TypeError("network error"));
});
const wrapped = withRfc8414OidcCompat(inner);
const response = await wrapped(RFC8414);
expect(response.status).toBe(404);
});
});

View file

@ -387,6 +387,46 @@ describe("withOAuthRequestTimeout", () => {
});
});
it("falls back to the default on a non-finite budget", async () => {
vi.useFakeTimers();
// `NaN` survives `Math.max(0, Math.round(NaN))` and `setTimeout(fn, NaN)`
// fires immediately, so without this every OAuth request under a budget
// that came out of bad arithmetic would fail at once with "timed out after
// NaNms".
for (const bad of [NaN, Infinity, -Infinity]) {
const wrapped = withOAuthRequestTimeout(neverSettles, bad);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(1);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
});
}
});
it("clamps an over-large budget to what setTimeout can schedule", async () => {
vi.useFakeTimers();
// Past 2**31-1 the delay overflows a 32-bit signed int and Node falls back
// to 1ms — an immediate timeout, the opposite of what was asked for.
const wrapped = withOAuthRequestTimeout(neverSettles, 2 ** 40);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(2_147_483_647);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: 2_147_483_647,
});
});
it("clamps a negative budget to zero rather than throwing", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles, -1);

View file

@ -473,10 +473,12 @@ describe("server.ts supplemental coverage", () => {
it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
// The caller's budget travels in the envelope, so this costs milliseconds
// rather than the production thirty seconds. The marker matters because
// a backend-enforced deadline is the only one on the transport-internal
// discovery path, and its error has to survive the hop as something an
// `instanceof OAuthRequestTimeoutError` check can still recognize.
// rather than the production thirty seconds. What it pins is typed-marker
// preservation when the *backend* wins the client-side race — both ends
// run the same budget, and a backgrounded tab throttling `setTimeout` is
// the plausible way the server's fires first. The error has to survive
// the hop as something an `instanceof OAuthRequestTimeoutError` check can
// still recognize, whichever end produced it.
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },