fix: cancel the discarded proxy stream, refresh revocation docs (#2319 round 8)

The /api/fetch route classifies an event-stream or NDJSON response as a
stream and then answers with JSON and no body — so nobody downstream
owns the upstream stream and nothing will ever read it, while the
`finally` clears the only deadline on the way out. It is now cancelled
explicitly, best-effort, the way `releaseBody` in oidcDiscoveryCompat
already does. Measured, undici reclaims an unread body here on its own,
so this is belt and braces rather than a demonstrated leak — kept
because relying on that is an implementation detail of the fetch
beneath us, not a contract. The route test and the source comment both
say so rather than implying a stronger claim.

Forwarding the signal also made revocation.ts's rationale stale: it
still described a proxy that drops `init.signal` and a backend that
cannot cancel. Both comments there, and the matching one in
revocation.test.ts, now describe the post-#2319 path — the signal
cancels, the race guarantees, and what the race is still for.

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 04:00:44 -04:00
commit a452305204
2 changed files with 79 additions and 21 deletions

View file

@ -268,14 +268,15 @@ export async function revokeToken(
// outcome — and every caller has already cleared its local state by now, so
// a rejection would break the documented best-effort guarantee.
const { url, init } = buildRevocationRequest(params);
// The signal alone is not enough to bound this. In the browser the fetch is
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
// and does not forward `init.signal`; the backend's outbound fetch gets no
// signal either. So a wedged authorization server would hold the teardown
// open indefinitely on exactly the path the timeout exists for. The race is
// what actually enforces the deadline; the signal is kept because it does
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
// abandoning them.
// The signal cancels; the race is what guarantees the deadline. Since #2319
// the signal reaches every path — `createRemoteFetch` forwards it onto the
// `/api/fetch` hop and that route composes it into its outbound fetch, so a
// browser-side abort now tears down the backend's request to the
// authorization server as well as the direct-fetch paths (CLI, TUI,
// backend) it always did. The race is still not redundant: it bounds a
// `fetchFn` that ignores `AbortSignal` altogether, and a backend that is
// itself wedged. Same reasoning, and the same shape, as
// `withOAuthRequestTimeout`.
const response = await withDeadline(
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
timeoutMs,
@ -307,11 +308,12 @@ export async function revokeToken(
/**
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
*
* The underlying request is abandoned rather than cancelled — nothing here can
* cancel a fetch the proxy already stripped the signal from. That is the right
* trade for this caller: the point is that the *teardown* proceeds, and the
* response, if it ever arrives, is a revocation we no longer need to wait for.
* The timer is cleared on the settled path so a caller is never held awake by it.
* This abandons the promise; cancelling the request is the caller's signal's job
* (see `revokeToken`, where the two are used together). Abandonment alone is an
* acceptable floor for this caller even where the signal is ignored: the point
* is that the *teardown* proceeds, and the response, if it ever arrives, is a
* revocation we no longer need to wait for. The timer is cleared on the settled
* path so a caller is never held awake by it.
*/
async function withDeadline<T>(
promise: Promise<T>,

View file

@ -1231,9 +1231,10 @@ export function createRemoteApp(
const signal = clientSignal
? AbortSignal.any([controller.signal, clientSignal])
: controller.signal;
// Cleared in `finally`, before the handler returns. A streaming response is
// returned here without its body being read, so a timer left armed would
// sever a live stream 30 seconds in.
// Cleared in `finally`, before the handler returns. Safe because by then
// the body has been either read or explicitly cancelled — this route never
// hands a live stream to its caller, so there is nothing left for the timer
// to protect and nothing it could sever.
const timer = setTimeout(
() =>
controller.abort(
@ -1271,6 +1272,19 @@ export function createRemoteApp(
let resBody: string | undefined;
if (!isStream && res.body) {
resBody = await res.text();
} else if (isStream) {
// This route does not return the stream — it answers with JSON and no
// body — so nobody downstream owns it and nothing will ever read it.
// Left un-cancelled, an OAuth endpoint that sends event-stream or
// NDJSON headers and never closes would hold the upstream socket open
// for good, since the `finally` below clears the only deadline once
// this handler returns (Copilot). Best-effort, like `releaseBody` in
// `oidcDiscoveryCompat`: a body already consumed, locked or absent is
// not an error here. Measured, undici does reclaim an unread body on
// its own in this configuration — so this is belt and braces rather
// than a demonstrated leak, and it is kept because relying on that is
// an implementation detail of the fetch beneath us, not a contract.
await res.body?.cancel().catch(() => {});
}
return c.json({