Compare commits

..

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
91ab21a9ce chore: sync release/v3.8.51 into fix/13306-windows-libuv-abort-sqljs-exit (base-red fix #13747) 2026-09-15 23:24:33 -03:00
diegosouzapw
9fab8da48d Merge commit '8f55d85d221e8df0b788eab0e598935a1514536a' into fix/13306-windows-libuv-abort-sqljs-exit 2026-09-15 23:19:37 -03:00
diegosouzapw
ddae648fec fix(db): defer process.exit(0) by a macrotask on graceful shutdown (#13306)
sql.js's Emscripten WASM build leaves pending libuv async-handle teardown
work in flight after a statement has run. gracefulShutdown.ts's shutdown
closure called process.exit(0) in the same tick as the cleanup promise
resolving, tearing the event loop down before that teardown settled. On
Windows, libuv's async-handle close path asserts
!(handle->flags & UV_HANDLE_CLOSING) when this happens, aborting the
process (exit 127); Linux's unix backend has no equivalent assertion,
which is why this was invisible on CI. Deferring process.exit(0) by one
macrotask (setTimeout(..., 0)) mirrors the pattern already used
throughout 9router's own shutdown call sites and gives sql.js's pending
libuv work a chance to settle first.

Regression test: tests/unit/graceful-shutdown-deferred-exit-13306.test.ts
pins the ordering contract (process.exit(0) must not fire in the same
microtask turn cleanup() resolves in). The Windows abort itself cannot
be reproduced on Linux CI — see the PR body for the required live
Windows validation.
2026-09-15 15:17:33 -03:00
9 changed files with 122 additions and 137 deletions

View File

@@ -1 +0,0 @@
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811

View File

@@ -0,0 +1 @@
- **fix(db):** defer `process.exit(0)` on graceful shutdown by one macrotask, avoiding a Windows-only libuv abort when the sql.js fallback driver has a statement in flight (#13306) — thanks @anhtahaylove

View File

@@ -1,18 +0,0 @@
/**
* Shared classification for browser-backed executors: distinguishes a missing Playwright
* Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream
* fault. This is a host/config problem, not something a retry loop can fix, so executors must
* NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the
* provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so
* every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection.
*/
export function isMissingBrowserExecutable(message: string): boolean {
if (!message) return false;
const lower = message.toLowerCase();
return (
lower.includes("executable doesn't exist") ||
lower.includes("executablenotfound") ||
lower.includes("playwright install") ||
(lower.includes("chromium") && lower.includes("download"))
);
}

View File

@@ -15,7 +15,6 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
@@ -28,12 +27,22 @@ import {
const GEMINI_URL = "https://gemini.google.com/app";
// Re-exported for backward compatibility: some tests/callers import this classification helper
// from gemini-web.ts, its original home (#3516). The implementation now lives in
// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can
// share it without importing this whole executor module.
export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
/**
* Whether an error came from Playwright failing to launch because the browser binary is not
* installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config
* problem, not a transient upstream fault, so the executor must NOT surface it as a retryable
* 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516.
*/
export function isMissingBrowserExecutable(message: string): boolean {
if (!message) return false;
const lower = message.toLowerCase();
return (
lower.includes("executable doesn't exist") ||
lower.includes("executablenotfound") ||
lower.includes("playwright install") ||
(lower.includes("chromium") && lower.includes("download"))
);
}
const GEMINI_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";

View File

@@ -51,7 +51,6 @@ import {
makeZaiChunkEmitter,
} from "./zai-web/stream.ts";
import { browserBackedChat } from "../services/browserBackedChat.ts";
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
import {
makeExecutorErrorResult as makeErrorResult,
@@ -425,26 +424,9 @@ export class ZaiWebExecutor extends BaseExecutor {
try {
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
} catch (error) {
const rawMessage = error instanceof Error ? error.message : "browser transport unavailable";
// #13232: a missing Playwright browser binary is a host/config problem, not a transient
// upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and
// tag it with the connection-cooldown hint so accountFallback skips the whole-provider
// circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown
// instead.
if (isMissingBrowserExecutable(rawMessage)) {
return {
errorResult: makeErrorResult(
503,
"Z.ai requires the Playwright Chromium browser, which is not installed. " +
"Run `npx playwright install chromium` on the host (or rebuild the Docker image " +
"with browsers).",
input.body,
ZAI_CHAT_URL,
{ "X-Omni-Fallback-Hint": "connection_cooldown" }
),
};
}
const message = sanitizeErrorMessage(rawMessage);
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "browser transport unavailable"
);
return {
errorResult: makeErrorResult(
502,

View File

@@ -1134,8 +1134,7 @@ export function makeExecutorErrorResult(
status: number,
message: string,
body: unknown,
url: string,
extraResponseHeaders?: Record<string, string>
url: string
) {
return {
response: new Response(
@@ -1146,10 +1145,7 @@ export function makeExecutorErrorResult(
code: `HTTP_${status}`,
},
}),
{
status,
headers: { "Content-Type": "application/json", ...extraResponseHeaders },
}
{ status, headers: { "Content-Type": "application/json" } }
),
url,
headers: {} as Record<string, string>,

View File

@@ -200,7 +200,16 @@ export function initGracefulShutdown(): void {
}
const shutdown = (signal: string) => {
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => process.exit(0));
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => {
// #13306: on Windows, sql.js's Emscripten WASM build leaves pending libuv
// async-handle teardown work in flight after a statement has run. Calling
// process.exit() in the same tick as cleanup() resolving tears the event loop
// down before that teardown settles, and libuv's Windows async-handle close path
// asserts `!(handle->flags & UV_HANDLE_CLOSING)` -> hard abort. Deferring by one
// macrotask (mirrors 9router's own shutdown call sites, e.g.
// appUpdater.js:199, cli/cli.js:675) gives that teardown work a chance to run.
setTimeout(() => process.exit(0), 0);
});
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));

View File

@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
type GracefulShutdownModule = typeof import("../../src/lib/gracefulShutdown.ts");
const gracefulShutdownUrl = pathToFileURL(join(process.cwd(), "src/lib/gracefulShutdown.ts")).href;
// #13306: on Windows, sql.js's Emscripten WASM build leaves pending libuv async-handle
// teardown work in flight after a statement has run (db.run()/adapter.exec()). Calling
// process.exit() tears the event loop down synchronously, and libuv's Windows async-handle
// close path asserts `!(handle->flags & UV_HANDLE_CLOSING)` while that teardown work is
// still pending -> hard abort (exit 127). The fix defers `process.exit(0)` by one macrotask
// after the shutdown cleanup promise resolves (mirrors the `setTimeout(() =>
// process.exit(0), n)` pattern already used throughout 9router's own shutdown call sites),
// giving sql.js's pending libuv work a chance to settle before the event loop tears down.
//
// The Windows abort itself cannot be reproduced here (Linux's unix libuv backend has no
// equivalent assertion, matching the reporter's own cross-platform matrix) — this test pins
// the *ordering* contract the fix depends on: process.exit(0) must not fire in the same
// microtask turn the cleanup promise resolves in, it must be deferred to a later macrotask.
test("graceful shutdown defers process.exit(0) to a macrotask after cleanup resolves (#13306)", async () => {
const previousState = globalThis.__omnirouteShutdown;
const previousRequestShutdown = globalThis.__omnirouteRequestShutdown;
const previousCustomServerOwner = globalThis.__omnirouteCustomServerOwnsShutdown;
const previousExit = process.exit;
const listenersBefore = process.listeners("SIGTERM");
delete globalThis.__omnirouteShutdown;
delete globalThis.__omnirouteCustomServerOwnsShutdown;
const exitCalls: Array<number | undefined> = [];
process.exit = ((code?: number) => {
exitCalls.push(code);
return undefined as never;
}) as typeof process.exit;
let resolveCleanup!: () => void;
const cleanupPromise = new Promise<void>((resolve) => {
resolveCleanup = resolve;
});
globalThis.__omnirouteRequestShutdown = () => cleanupPromise;
try {
const shutdownModule = (await import(
`${gracefulShutdownUrl}?issue13306=${Date.now()}`
)) as GracefulShutdownModule;
shutdownModule.initGracefulShutdown();
const addedListener = process
.listeners("SIGTERM")
.find((listener) => !listenersBefore.includes(listener));
assert.ok(addedListener, "initGracefulShutdown() must register a new SIGTERM listener");
// Trigger the shutdown closure directly — do NOT emit a real SIGTERM in the test process.
(addedListener as () => void)();
resolveCleanup();
// Let the shutdown closure's own `.then()` continuation run: it was attached to
// `cleanupPromise` before this `await`, so it settles first on the microtask queue.
await cleanupPromise;
await Promise.resolve();
assert.deepEqual(
exitCalls,
[],
"process.exit(0) must not fire in the same microtask turn the cleanup promise resolves in"
);
// Now let a macrotask elapse — this is where the deferred process.exit(0) must land.
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(exitCalls, [0], "process.exit(0) must still run, deferred by one macrotask");
} finally {
process.exit = previousExit;
for (const listener of process.listeners("SIGTERM")) {
if (!listenersBefore.includes(listener)) process.removeListener("SIGTERM", listener);
}
if (previousState === undefined) delete globalThis.__omnirouteShutdown;
else globalThis.__omnirouteShutdown = previousState;
if (previousRequestShutdown === undefined) delete globalThis.__omnirouteRequestShutdown;
else globalThis.__omnirouteRequestShutdown = previousRequestShutdown;
if (previousCustomServerOwner === undefined) {
delete globalThis.__omnirouteCustomServerOwnsShutdown;
} else {
globalThis.__omnirouteCustomServerOwnsShutdown = previousCustomServerOwner;
}
}
});

View File

@@ -1,83 +0,0 @@
/**
* Regression for GitHub issue #13232 — "[BUG] Z.ai web error".
*
* The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past
* Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing,
* `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts
* had no classification for that failure and surfaced it as a plain 502 with no fallback hint —
* a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit
* Breaker") as if the upstream itself were failing, instead of applying the intended
* host/config connection cooldown. This mirrors the exact failure class already handled for
* Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via
* `open-sse/executors/browserExecutableCheck.ts`).
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Buffer } from "node:buffer";
const mod = await import("../../open-sse/executors/zai-web.ts");
const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`;
describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => {
let emptyBrowsersDir: string;
let originalBrowsersPath: string | undefined;
before(() => {
emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-"));
originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
// Force chromium.launch() to genuinely fail with the exact class of error the reporter hit
// ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright
// install.
process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir;
});
after(() => {
if (originalBrowsersPath === undefined) {
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
} else {
process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath;
}
fs.rmSync(emptyBrowsersDir, { recursive: true, force: true });
});
it(
"returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " +
"502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)",
async () => {
const executor = new mod.ZaiWebExecutor();
const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] };
const result = await executor.execute({
model: "glm-5.3-flash",
body,
stream: false,
credentials: { apiKey: TEST_TOKEN },
signal: null,
});
assert.ok("response" in result, "expected an error Response, not a stream result");
const response = (result as { response: Response }).response;
const payload = (await response.json()) as { error?: { message?: string } };
assert.equal(
response.status,
503,
"zai-web must classify a missing local Chromium install as a host/config error (503), " +
"not a generic retryable 502 that trips the whole-provider circuit breaker."
);
assert.equal(
response.headers.get("X-Omni-Fallback-Hint"),
"connection_cooldown",
"the connection-cooldown hint must be set so accountFallback applies a short cooldown " +
"instead of tripping the provider circuit breaker."
);
assert.match(
payload.error?.message ?? "",
/Playwright Chromium browser.*not installed.*npx playwright install chromium/s
);
}
);
});