Files
open-design/apps/packaged/src/protocol.ts
33fd35e07b fix(packaged): treat Chromium ERR_NO_BUFFER_SPACE as local resource exhaustion (#8283)
Electron net.fetch reports ENOBUFS as net::ERR_NO_BUFFER_SPACE. The existing
guard only matched the POSIX name, so the proxy fell through to undici and
kept retrying on an already-exhausted T2 Intel Mac.

Co-authored-by: leoric <leoricchen@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 07:11:00 +00:00

546 lines
21 KiB
TypeScript

import { net, protocol } from "electron";
const OD_SCHEME = "od";
const OD_ENTRY_URL = `${OD_SCHEME}://app/`;
type OdProtocolFetch = (request: Request) => Promise<Response>;
/**
* The web sidecar's HTTP origin, or `null` when there is no usable one.
*
* `WebStatusSnapshot.url` is nullable at the source, so the nullability is
* carried all the way into the proxy instead of being papered over at the
* registration site with a placeholder address.
*/
type OdProtocolTarget = string | null;
/**
* Supplies the CURRENT web sidecar address, consulted once per proxied
* request.
*
* Deliberately a provider and not a plain string. The packaged restart
* supervisor can respawn the web sidecar on a fresh ephemeral port, so a
* snapshot would keep dialling the retired process. Resolving per request also
* preserves the explicit unavailable state while a replacement starts.
*/
type OdProtocolTargetResolver = () => OdProtocolTarget;
export type OdProtocolControl = {
/** Stop forwarding renderer traffic before the current runtime retires. */
quiesce(): void;
};
protocol.registerSchemesAsPrivileged([
{
privileges: {
corsEnabled: true,
secure: true,
standard: true,
stream: true,
supportFetchAPI: true,
},
scheme: OD_SCHEME,
},
]);
/**
* Rewrite an incoming `od://` request onto the web sidecar's current address,
* or report that there is nothing to rewrite onto.
*
* Returns `null` — rather than throwing — for both "no address" and "address
* is not a URL". Throwing here used to escape the handler entirely (the call
* sat outside its try/catch), which is precisely the unhandled-rejection shape
* that surfaces as Electron's "JavaScript error in main process" dialog and
* that the #895 catch exists to prevent. A `null` lets the caller answer with
* an honest status instead.
*/
function toWebRuntimeUrl(webRuntimeUrl: OdProtocolTarget, requestUrl: string): string | null {
if (webRuntimeUrl == null || webRuntimeUrl.length === 0) return null;
let target: URL;
try {
target = new URL(webRuntimeUrl);
} catch {
return null;
}
const incoming = new URL(requestUrl);
target.pathname = incoming.pathname;
target.search = incoming.search;
target.hash = incoming.hash;
return target.toString();
}
const OD_PROXY_RETRYABLE_METHODS = new Set(["GET", "HEAD"]);
/**
* Error tokens that mean the LOCAL machine is out of network resources —
* sockets, file descriptors, ephemeral ports, kernel buffers. Kept as an
* explicit whitelist: everything not listed here retains the proxy's normal
* resilience behavior (undici fallback, transient retry).
*/
const OD_RESOURCE_EXHAUSTION_ERROR_TOKENS: readonly string[] = [
"ERR_INSUFFICIENT_RESOURCES",
"EMFILE",
"ENFILE",
"EADDRNOTAVAIL",
"ENOBUFS",
"ERR_NO_BUFFER_SPACE", // Chromium's name for ENOBUFS (Electron net.fetch)
];
/**
* Is this failure the local machine running out of network resources?
*
* INVARIANT: a request that fails because this machine is out of
* sockets/file descriptors must cost exactly ONE upstream attempt. Both of
* the proxy's resilience layers amplify load, and when the failure is
* exhaustion, amplification is precisely the wrong medicine: in the incident
* that motivated this classifier, the undici fallback re-issued every failed
* net.fetch (7890 doubled requests measured) and the linear-backoff transient
* retry stacked another 1334 attempts on top — each new attempt consuming
* exactly the resource the machine had already run out of, and prolonging the
* outage it was trying to ride through.
*
* Matching looks at BOTH `Error.code` and the message because the two fetch
* transports report differently: Electron's net.fetch surfaces Chromium
* network-stack failures as plain Errors whose message carries the
* `net::ERR_...` token while `code` stays unset, whereas Node/undici throws
* ErrnoExceptions that carry the token in `code`.
*/
function isLocalResourceExhaustionError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = (error as NodeJS.ErrnoException).code;
return OD_RESOURCE_EXHAUSTION_ERROR_TOKENS.some(
(token) => code === token || error.message.includes(token),
);
}
const OD_PROXY_RETRY_ATTEMPTS = 3;
const OD_PROXY_RETRY_BACKOFF_MS = 150; // 150ms, 300ms — throw path only, ~450ms worst-case added
type OdProxyRetryOptions = {
activeControllers?: Set<AbortController>;
attempts?: number;
backoffMs?: number;
delay?: (ms: number) => Promise<void>;
isQuiesced?: () => boolean;
};
const defaultRetryDelay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Fetch the rewritten sidecar target, absorbing transient socket throws
* for idempotent requests.
*
* A single transient fetch failure must never become the document the
* window renders: undici can throw mid-fetch from socket internals (the
* `setTypeOfService EINVAL` family of issue #895) even while the web
* sidecar is healthy, and when that happens on the top navigation
* (`od://app/`) the synthetic 502 from `buildProxyErrorResponse` IS the
* whole window — the React app never mounts and nothing reloads it.
* GET/HEAD carry no body, so re-issuing the Request per attempt is safe;
* non-idempotent methods keep single-attempt semantics. Responses that
* resolve — including upstream 5xx — are never retried here: those are
* app-level answers the renderer owns.
*
* Deliberately not conditioned on `Sec-Fetch-Dest: document`: uniform
* idempotent retry is simpler, and Sec-Fetch header presence on a custom
* scheme is not a stable contract across Electron versions.
*/
/**
* One proxied fetch with EXPLICIT cancellation plumbing. The renderer's 6-per-
* origin connection pool made this load-bearing: an EventSource the renderer
* closes (or a fetch it aborts) MUST release the upstream net.fetch connection,
* or long-lived streams (workspace/collab SSE, chat run streams) leak pool
* slots until every od:// request in the app hangs forever — tofu icons,
* dead fetches, the works. Electron's protocol.handle does not reliably
* propagate client disconnects to the handler's Response, so we wire BOTH
* paths ourselves:
* - request.signal abort (when Electron does fire it) aborts the upstream;
* - the returned body stream's cancel() (fired when the protocol consumer
* tears down) aborts the upstream too.
*/
/**
* Two response-header repairs every proxied response needs:
*
* 1. Drop `content-encoding`/`content-length`/`transfer-encoding`. The
* main-process fetch has ALREADY decompressed the upstream body, but the
* stale headers ride along, so Chromium's renderer-side consumers that
* honor them (the font loader chief among them) try to gunzip plain
* bytes and fail with a generic network error. That is exactly why every
* packaged icon font rendered as tofu squares while plain fetch() of the
* same TTF returned 200 — the fetch path never re-decodes. Classic
* protocol.handle-proxy pitfall.
* 2. Stamp `Access-Control-Allow-Origin: *`. Font loads are CORS-mode
* requests per the CSS Fonts spec; od:// is reachable only from this
* app's own renderer, so the wildcard leaks nothing.
*/
function sanitizeProxyResponseHeaders(headers: Headers): Headers {
const out = new Headers(headers);
out.delete("content-encoding");
out.delete("content-length");
out.delete("transfer-encoding");
out.set("Access-Control-Allow-Origin", "*");
return out;
}
async function fetchOdTargetOnce(
request: Request,
target: string,
fetchImpl: OdProtocolFetch,
options: OdProxyRetryOptions,
): Promise<Response> {
const controller = new AbortController();
options.activeControllers?.add(controller);
const release = () => {
request.signal?.removeEventListener("abort", abortUpstream);
options.activeControllers?.delete(controller);
};
const abortUpstream = () => controller.abort();
if (request.signal != null) {
if (request.signal.aborted) controller.abort();
else request.signal.addEventListener("abort", abortUpstream, { once: true });
}
let upstream: Response;
try {
upstream = await fetchImpl(
new Request(target, {
method: request.method,
headers: request.headers,
body: request.body,
// @ts-expect-error -- duplex is required by undici/net.fetch for
// streaming request bodies and absent from the lib.dom Request typings.
duplex: "half",
signal: controller.signal,
}),
);
} catch (error) {
release();
throw error;
}
if (upstream.body == null) {
release();
return new Response(null, {
status: upstream.status,
statusText: upstream.statusText,
headers: sanitizeProxyResponseHeaders(upstream.headers),
});
}
const reader = upstream.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(streamController) {
try {
const { done, value } = await reader.read();
if (done) {
release();
streamController.close();
return;
}
streamController.enqueue(value);
} catch (error) {
release();
streamController.error(error);
}
},
cancel() {
// Protocol consumer went away (renderer closed the EventSource, tab
// navigated, fetch aborted) — release the upstream connection NOW.
abortUpstream();
release();
return reader.cancel().catch(() => undefined);
},
});
return new Response(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: sanitizeProxyResponseHeaders(upstream.headers),
});
}
/**
* Did the RENDERER walk away from this request?
*
* Rapid navigation (project page ⇄ Community) cancels the in-flight
* subresource requests of the page being left — that is Chromium working as
* designed, not a fault. It matters here because `fetchOdTargetOnce` forwards
* the incoming signal to the upstream, so a cancelled request rejects: without
* this check the retry loop treats it as a transient socket fault, re-issues a
* request whose signal is ALREADY aborted (so every remaining attempt fails
* instantly), and the handler then manufactures a 502 for a response nobody is
* going to read. Packaged 0.16.2-beta.139 logged 84 such "proxy fetch failed"
* warnings — all fonts, icons and a `presence/leave` POST, all clustered inside
* navigation bursts — which is pure noise masquerading as a gateway outage.
*/
function isClientCancelled(request: Request): boolean {
return request.signal?.aborted === true;
}
const OD_CLIENT_CANCELLED_STATUS = 499; // nginx's "Client Closed Request"
/**
* A cancelled request still needs SOME response — rejecting would bubble to
* Electron's uncaught handler (the #895 dialog). Answer with a status that is
* honestly distinguishable from an upstream failure so logs and any consumer
* that does read it can tell "you cancelled this" from "the sidecar is down".
*/
function buildClientCancelledResponse(target: string): Response {
return new Response(
JSON.stringify({ error: "OD_PROTOCOL_CLIENT_ABORTED", target }),
{
status: OD_CLIENT_CANCELLED_STATUS,
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
},
);
}
const OD_TARGET_UNAVAILABLE_STATUS = 503; // Service Unavailable
function buildRuntimeRetiringResponse(request: Request): Response {
return new Response(
JSON.stringify({
error: "OD_PROTOCOL_RUNTIME_RETIRING",
message: "the packaged runtime is restarting",
requested: request.url,
}),
{
status: OD_TARGET_UNAVAILABLE_STATUS,
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
},
);
}
/**
* Answer for a request that has nowhere to go: the web sidecar reported no
* address, or reported one that will not parse.
*
* The registration site used to substitute `http://127.0.0.1:0` whenever the
* address was missing. Port 0 means "kernel, pick me a free port" to a
* *listener*; as a *connect* target it is meaningless, so that fallback turned
* "the web runtime is not up" into a connection error against a nonsense port
* — a misleading symptom that costs real time to chase. 503 says the true
* thing, and is distinguishable from both the 502 upstream-failure document
* and the 499 client-cancellation document.
*/
function buildTargetUnavailableResponse(request: Request, address: OdProtocolTarget): Response {
return new Response(
JSON.stringify({
error: "OD_PROTOCOL_TARGET_UNAVAILABLE",
message:
address == null || address.length === 0
? "web sidecar reported no address"
: "web sidecar address is not a valid URL",
...(address == null || address.length === 0 ? {} : { address }),
requested: request.url,
}),
{
status: OD_TARGET_UNAVAILABLE_STATUS,
headers: {
"content-type": "application/json",
// Same rationale as the 502 path: keep the failure readable from
// CORS-mode consumers instead of masking it as an opaque network error.
"access-control-allow-origin": "*",
},
},
);
}
async function fetchOdTargetWithTransientRetry(
request: Request,
target: string,
fetchImpl: OdProtocolFetch,
options: OdProxyRetryOptions,
): Promise<Response> {
const attempts = OD_PROXY_RETRYABLE_METHODS.has(request.method)
? (options.attempts ?? OD_PROXY_RETRY_ATTEMPTS)
: 1;
const backoffMs = options.backoffMs ?? OD_PROXY_RETRY_BACKOFF_MS;
const delay = options.delay ?? defaultRetryDelay;
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (options.isQuiesced?.()) {
throw new Error("packaged runtime is retiring");
}
try {
return await fetchOdTargetOnce(request, target, fetchImpl, options);
} catch (error) {
lastError = error;
if (options.isQuiesced?.()) break;
// Retrying a request the client abandoned can only fail again, and each
// extra attempt is a misleading "proxy fetch failed" line in the log.
if (isClientCancelled(request)) break;
// Resource exhaustion fails fast: every extra attempt consumes exactly
// the resource the machine has run out of. ECONNREFUSED is deliberately
// NOT in that whitelist — this retry budget is what bridges the daemon
// startup race. See isLocalResourceExhaustionError.
if (isLocalResourceExhaustionError(error)) break;
if (attempt === attempts) break;
const waitMs = backoffMs * attempt;
// Main-process console output lands in the packaged desktop logs, so
// real-world transient frequency stays diagnosable.
console.warn("[open-design packaged] od:// proxy fetch failed; retrying", {
attempt,
attempts,
message: error instanceof Error ? error.message : String(error),
target,
waitMs,
});
await delay(waitMs);
}
}
throw lastError;
}
function buildProxyErrorResponse(error: unknown, target: string): Response {
const message = error instanceof Error ? error.message : String(error);
const code =
error instanceof Error && typeof (error as NodeJS.ErrnoException).code === "string"
? (error as NodeJS.ErrnoException).code
: null;
return new Response(
JSON.stringify({
error: "OD_PROTOCOL_PROXY_FAILED",
message,
...(code === null ? {} : { code }),
target,
}),
{
status: 502,
headers: {
"content-type": "application/json",
// Keep proxy failures readable from CORS-mode consumers (fonts,
// module scripts) instead of masking them as opaque network errors.
"access-control-allow-origin": "*",
},
},
);
}
/**
* Inner request handler for the `od://` Electron protocol — every
* renderer fetch flows through here and gets proxied to the local web
* sidecar via Node's global `fetch` (which is undici under the hood).
*
* Pulled out as a named export so unit tests can drive it with a stub
* `fetchImpl` without spinning up Electron, and so the try/catch
* stays auditable from one place.
*
* Why the try/catch matters: undici can throw `setTypeOfService
* EINVAL` from socket internals on certain macOS / VPN configurations
* (issue #895). Without the catch, the rejection bubbles all the way
* up to the Electron main process and surfaces as a native
* "JavaScript error in main process" dialog the next time the user
* does anything that triggers a renderer-to-sidecar fetch (e.g.
* Settings → Pets → Community). Returning a 502 instead lets the
* renderer see a normal failure and keeps the process alive.
*
* Idempotent requests first pass through
* `fetchOdTargetWithTransientRetry`, so a one-off transient throw on
* the top navigation cannot end up as the 502 document covering the
* window; the 502 remains the exhaustion fallback.
*/
export async function handleOdRequest(
request: Request,
webRuntimeUrl: OdProtocolTarget,
fetchImpl: OdProtocolFetch = fetch,
retryOptions: OdProxyRetryOptions = {},
): Promise<Response> {
const target = toWebRuntimeUrl(webRuntimeUrl, request.url);
// No address to proxy to. Say so plainly rather than dialling a placeholder.
if (target == null) return buildTargetUnavailableResponse(request, webRuntimeUrl);
try {
return await fetchOdTargetWithTransientRetry(request, target, fetchImpl, retryOptions);
} catch (error) {
if (retryOptions.isQuiesced?.()) return buildRuntimeRetiringResponse(request);
// A request the renderer cancelled is not a gateway failure — reporting it
// as 502 both lies in the logs and hands error-shaped JSON to any consumer
// that fails to check `res.ok`.
if (isClientCancelled(request)) return buildClientCancelledResponse(target);
return buildProxyErrorResponse(error, target);
}
}
export function packagedEntryUrl(): string {
return OD_ENTRY_URL;
}
/**
* Route the od:// proxy through Electron's `net.fetch` (Chromium's network
* stack, ~6 pooled connections per origin like a browser tab) instead of Node's
* global `fetch` (undici), which caps a single origin far lower. Proxying every
* renderer request through undici serialized them: a project-open request burst
* that a browser tab runs in parallel dribbled out over minutes in the packaged
* app. `net.fetch` gives the packaged client the same concurrency as the
* browser.
*
* Electron can nevertheless reject particular renderer-owned subresource
* requests with a generic `net::ERR_FAILED` even while the same URL succeeds
* through ordinary fetch and the sidecar returns 200. CSS mask images are one
* concrete case: packaged agent icons disappeared while their SVG files were
* present and directly readable. For GET/HEAD only, fall back to undici after
* that transport-level rejection. Those methods are safe to replay; write
* requests deliberately stay on a single transport.
*
* Set OD_OD_PROXY_FETCH=undici to force the old path for all requests if
* `net.fetch` regresses a broader streaming edge on a specific Electron/OS
* combo.
*/
function resolveOdProxyFetch(isQuiesced: () => boolean = () => false): OdProtocolFetch {
if (process.env.OD_OD_PROXY_FETCH === "undici") return fetch;
const undiciFetch = fetch;
return async (request) => {
try {
return await net.fetch(request);
} catch (error) {
if (isQuiesced()) throw error;
if (!OD_PROXY_RETRYABLE_METHODS.has(request.method) || isClientCancelled(request)) {
throw error;
}
// The fallback exists to rescue Electron-specific transport rejections
// (net::ERR_FAILED on CSS-mask GETs) that undici serves fine. Local
// resource exhaustion is not that: undici draws on the same exhausted
// machine, so replaying there only doubles the failing load. Rethrow and
// let the handler answer 502 at once. See isLocalResourceExhaustionError.
if (isLocalResourceExhaustionError(error)) throw error;
console.warn("[open-design packaged] net.fetch failed; falling back to undici", {
message: error instanceof Error ? error.message : String(error),
method: request.method,
target: request.url,
});
return await undiciFetch(request);
}
};
}
/**
* Install the `od://` handler, resolving the proxy target through
* `resolveWebRuntimeUrl` on EVERY request.
*
* See `OdProtocolTargetResolver` for why this takes a provider rather than the
* address itself.
*/
export function registerOdProtocol(resolveWebRuntimeUrl: OdProtocolTargetResolver): OdProtocolControl {
let quiesced = false;
const activeControllers = new Set<AbortController>();
const fetchImpl = resolveOdProxyFetch(() => quiesced);
protocol.handle(OD_SCHEME, async (request) => {
// BrowserWindow.close() may be held by a renderer beforeunload guard. The
// transport barrier is therefore the authoritative boundary: once armed,
// no request can race sidecar retirement or dial the old localhost origin.
if (quiesced) return buildRuntimeRetiringResponse(request);
return await handleOdRequest(request, resolveWebRuntimeUrl(), fetchImpl, {
activeControllers,
isQuiesced: () => quiesced,
});
});
return {
quiesce() {
quiesced = true;
for (const controller of activeControllers) controller.abort();
activeControllers.clear();
},
};
}