mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 19:32:20 +03:00
* test(infra): retry recursive temp-dir removal on main (main twin of #11968)
`main` has been red since b342c1a361 on the vitest and integration gates:
✖ tests/unit/autoCombo/provider-family-combos.test.ts > auto/<family>
✖ chat pipeline applies Codex OAuth fingerprint and priority tier inside combos
Both call resetStorage() from beforeEach, which does an fs.rmSync(TEST_DATA_DIR,
{recursive: true, force: true}) with no retry, and intermittently loses the race
with a not-yet-released SQLite handle (ENOTEMPTY).
release/v3.8.51 fixed this in #11968 with a mechanical codemod adding
maxRetries/retryDelay to every recursive rm/rmSync/rmdirSync under tests/, but
that PR landed only on the release branch. Because main only receives work at
the release squash, it stayed broken for the whole cycle — and repo-wide gates
then turn every open PR into main red on checks unrelated to their diff.
This is the --base main twin: re-runs the same codemod that already shipped on
the release branch (scripts/ad-hoc/codemod-rm-maxretries.mjs), so the two
branches converge on identical test-teardown semantics. Test-only; no product
logic is touched.
The remaining three failures reported on #12133 (unit full suite exceeding its
4800s ceiling, package-artifact exceeding 1200s, and the boot-smoke that is
skipped as a consequence) are runner-contention timeouts, not code defects —
validate-release-green.mjs runs those heavy gates concurrently on one shared
hosted runner. There is no fix to port for those.
* chore(scripts): carry the rm-maxretries codemod onto main alongside its output
The codemod that generated the previous commit lives in the repo on
release/v3.8.51 (added by #11968) but was never on main. Bringing it over keeps
the tool next to the change it produced, so the transformation stays
reproducible and auditable from either branch.
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
// Issue #8181: a raw `process.stderr.write` into a broken pipe fails ASYNCHRONOUSLY, so the
|
|
// `try {} catch {}` around it in structuredLogger cannot catch it. The stream emits 'error';
|
|
// because nothing listens on process.stderr, Node re-throws it as an uncaughtException; the
|
|
// framework's handler logs that via console.error; consoleInterceptor's patched console.error
|
|
// appends to disk and re-writes to the same dead stream — closing a self-sustaining loop.
|
|
// Measured in a spawn harness: 3,387 uncaughtExceptions in 1.5s, versus 0 with an 'error'
|
|
// listener attached.
|
|
//
|
|
// The listener is the whole fix, but attaching one absorbs EVERY stream error on those
|
|
// streams, converting conditions that are fatal today (ENOSPC, EBADF) into silent no-ops.
|
|
// So EPIPE is absorbed and everything else is re-raised. The ENOSPC test below is the guard
|
|
// against that regression — it is the test that a latch-state assertion would not catch.
|
|
//
|
|
// Configure file logging BEFORE importing the interceptor: consoleInterceptor.ts reads
|
|
// getAppLogToFile() at module load, and tests/_setup/isolateDataDir.ts sets
|
|
// APP_LOG_TO_FILE ||= "false", which would otherwise make initConsoleInterceptor() a no-op
|
|
// and every assertion here vacuous. Static imports hoist, so this must be a dynamic import.
|
|
const dir = mkdtempSync(join(tmpdir(), "omniroute-interceptor-8181-"));
|
|
const logFile = join(dir, "logs", "application", "app.log");
|
|
|
|
// Capture the prior values so test.after() can put them back. test:unit:fast runs with
|
|
// --test-isolation=none, so these top-level mutations would otherwise leak into every later
|
|
// test file in the process, leaving file logging enabled against a path this file deletes.
|
|
const prevLogToFile = process.env.APP_LOG_TO_FILE;
|
|
const prevLogFilePath = process.env.APP_LOG_FILE_PATH;
|
|
|
|
process.env.APP_LOG_TO_FILE = "true";
|
|
process.env.APP_LOG_FILE_PATH = logFile;
|
|
|
|
const { initConsoleInterceptor, __consoleInterceptorInternals } =
|
|
await import("../../../src/lib/consoleInterceptor.ts");
|
|
|
|
function withUncaughtRecorder(): {
|
|
seen: () => unknown;
|
|
restore: () => void;
|
|
} {
|
|
let uncaught: unknown = null;
|
|
const onUncaught = (err: unknown) => {
|
|
uncaught = err;
|
|
};
|
|
process.on("uncaughtException", onUncaught);
|
|
return {
|
|
seen: () => uncaught,
|
|
restore: () => process.removeListener("uncaughtException", onUncaught),
|
|
};
|
|
}
|
|
|
|
const settle = () => new Promise((r) => setTimeout(r, 50));
|
|
|
|
test("init attaches an 'error' listener to process.stdout and process.stderr (#8181)", () => {
|
|
__consoleInterceptorInternals.reset();
|
|
const beforeOut = process.stdout.listenerCount("error");
|
|
const beforeErr = process.stderr.listenerCount("error");
|
|
|
|
initConsoleInterceptor();
|
|
|
|
assert.ok(
|
|
process.stdout.listenerCount("error") > beforeOut,
|
|
"process.stdout must carry an 'error' listener — without one, an async EPIPE re-throws " +
|
|
"as an uncaughtException and closes the #8181 loop"
|
|
);
|
|
assert.ok(
|
|
process.stderr.listenerCount("error") > beforeErr,
|
|
"process.stderr must carry an 'error' listener (#8181)"
|
|
);
|
|
});
|
|
|
|
test("an EPIPE on process.stderr does not surface as an uncaughtException (#8181)", async () => {
|
|
__consoleInterceptorInternals.reset();
|
|
initConsoleInterceptor();
|
|
const rec = withUncaughtRecorder();
|
|
try {
|
|
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
|
try {
|
|
process.stderr.emit("error", err);
|
|
} catch (syncThrow) {
|
|
assert.fail(
|
|
`EPIPE must be absorbed by the listener, not thrown synchronously: ${String(syncThrow)}`
|
|
);
|
|
}
|
|
await settle();
|
|
assert.equal(
|
|
rec.seen(),
|
|
null,
|
|
`an EPIPE stream error must be absorbed, not raised: ${String(rec.seen())}`
|
|
);
|
|
} finally {
|
|
rec.restore();
|
|
}
|
|
});
|
|
|
|
// NOTE: there is deliberately no "emit EPIPE on process.stdout" test here. Emitting a
|
|
// synthetic 'error' on the real process.stdout destroys node:test's own reporting — the
|
|
// runner writes its results to that stream, so every subsequent per-test line is swallowed
|
|
// and the file reports a bare failure with no diagnostic. (Verified: a 3-test probe emitting
|
|
// on stdout reported `pass 3, fail 0` but printed only the file line.) The stdout path is
|
|
// covered structurally by the listener-count assertion above; its behaviour is identical to
|
|
// stderr's because both streams share one handler.
|
|
test("the same handler is registered on both streams, so stdout behaves as stderr does", () => {
|
|
__consoleInterceptorInternals.reset();
|
|
initConsoleInterceptor();
|
|
|
|
const outListeners = process.stdout.listeners("error");
|
|
const errListeners = process.stderr.listeners("error");
|
|
const shared = outListeners.filter((fn) => errListeners.includes(fn));
|
|
|
|
assert.ok(
|
|
shared.length > 0,
|
|
"process.stdout and process.stderr must share the interceptor's error handler — the " +
|
|
"stdout EPIPE path cannot be exercised directly without breaking the test reporter, so " +
|
|
"this identity check is what proves it is wired the same way"
|
|
);
|
|
});
|
|
|
|
// The regression guard, and the reason it runs in a child process.
|
|
//
|
|
// Attaching an 'error' listener silently makes EVERY stream error non-fatal, process-wide —
|
|
// ENOSPC, EBADF, ECONNRESET included. A test that only asserts internal latch/handler state
|
|
// passes while shipping exactly that regression, so the assertion has to be behavioural.
|
|
//
|
|
// It cannot be asserted in-process: the re-raise surfaces as an uncaughtException, and
|
|
// node:test attributes any uncaughtException during a test to that test and fails it. So we
|
|
// assert the real guarantee — "a non-EPIPE stream error still terminates the process" — by
|
|
// measuring a child's exit code, which is the semantics callers actually depend on.
|
|
test("a non-EPIPE stream error is still fatal: it must be re-raised (#8181)", async () => {
|
|
const interceptor = fileURLToPath(
|
|
new URL("../../../src/lib/consoleInterceptor.ts", import.meta.url)
|
|
);
|
|
const childDir = mkdtempSync(join(tmpdir(), "omniroute-interceptor-8181-child-"));
|
|
const childFile = join(childDir, "reraise-probe.mts");
|
|
|
|
writeFileSync(
|
|
childFile,
|
|
[
|
|
`process.env.APP_LOG_TO_FILE = "true";`,
|
|
`process.env.APP_LOG_FILE_PATH = ${JSON.stringify(join(childDir, "logs", "application", "app.log"))};`,
|
|
`const m = await import(${JSON.stringify(interceptor)});`,
|
|
`m.initConsoleInterceptor();`,
|
|
`process.stderr.emit("error", Object.assign(new Error("disk full"), { code: "ENOSPC" }));`,
|
|
`setTimeout(() => process.exit(0), 300);`, // if the error was absorbed, we exit 0 = regression
|
|
].join("\n")
|
|
);
|
|
|
|
const result = spawnSync(process.execPath, ["--import", "tsx/esm", childFile], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
env: { ...process.env, DISABLE_SQLITE_AUTO_BACKUP: "true" },
|
|
});
|
|
|
|
rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
|
|
assert.notEqual(
|
|
result.status,
|
|
0,
|
|
"a non-EPIPE stream error must still terminate the process. Exit 0 means the interceptor's " +
|
|
"'error' listener swallowed it, silently converting a condition that is fatal today into " +
|
|
"a no-op for all code in the process, not just the interceptor."
|
|
);
|
|
assert.match(
|
|
String(result.stderr),
|
|
/ENOSPC/,
|
|
"the original error must be re-raised unchanged, preserving its code"
|
|
);
|
|
});
|
|
|
|
// The stdio guard must not depend on file logging. initConsoleInterceptor() returns early when
|
|
// APP_LOG_TO_FILE=false (and when the log directory cannot be created), but structuredLogger's
|
|
// raw stderr writes still happen in those configurations, so the loop would remain reachable
|
|
// if the listeners were only installed on the interception path.
|
|
test("the stdio guard is installed even when file logging is disabled (#8181)", () => {
|
|
const probe = fileURLToPath(new URL("../../../src/lib/consoleInterceptor.ts", import.meta.url));
|
|
const childDir = mkdtempSync(join(tmpdir(), "omniroute-interceptor-8181-nofile-"));
|
|
const childFile = join(childDir, "nofile-probe.mts");
|
|
|
|
writeFileSync(
|
|
childFile,
|
|
[
|
|
`process.env.APP_LOG_TO_FILE = "false";`, // interception off
|
|
`const m = await import(${JSON.stringify(probe)});`,
|
|
`m.initConsoleInterceptor();`,
|
|
`const ok = process.stderr.listenerCount("error") > 0 && process.stdout.listenerCount("error") > 0;`,
|
|
`process.exit(ok ? 0 : 7);`,
|
|
].join("\n")
|
|
);
|
|
|
|
const result = spawnSync(process.execPath, ["--import", "tsx/esm", childFile], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
env: { ...process.env, DISABLE_SQLITE_AUTO_BACKUP: "true", APP_LOG_TO_FILE: "false" },
|
|
});
|
|
|
|
rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
"with APP_LOG_TO_FILE=false the interceptor does not patch console, but the stdio error " +
|
|
"guard must still be installed: structuredLogger writes to stderr directly in that " +
|
|
"configuration, so an async EPIPE would otherwise still become an uncaughtException"
|
|
);
|
|
});
|
|
|
|
test("reset() removes the stream listeners and restores the original console methods", () => {
|
|
__consoleInterceptorInternals.reset();
|
|
const baselineErrListeners = process.stderr.listenerCount("error");
|
|
const pristineConsoleError = console.error;
|
|
|
|
initConsoleInterceptor();
|
|
assert.notEqual(
|
|
console.error,
|
|
pristineConsoleError,
|
|
"sanity: init must actually patch console.error"
|
|
);
|
|
|
|
__consoleInterceptorInternals.reset();
|
|
|
|
assert.equal(
|
|
process.stderr.listenerCount("error"),
|
|
baselineErrListeners,
|
|
"reset() must remove the listeners it added — otherwise repeated init across test files " +
|
|
"under --test-isolation=none accumulates listeners"
|
|
);
|
|
assert.equal(
|
|
console.error,
|
|
pristineConsoleError,
|
|
"reset() must restore the original console.error — 136 test sites mock console, and a " +
|
|
"leaked patched method would be captured by their save/restore"
|
|
);
|
|
});
|
|
|
|
test("re-init after reset() does not double-register stream listeners", () => {
|
|
__consoleInterceptorInternals.reset();
|
|
const baseline = process.stderr.listenerCount("error");
|
|
|
|
initConsoleInterceptor();
|
|
const afterFirst = process.stderr.listenerCount("error");
|
|
__consoleInterceptorInternals.reset();
|
|
initConsoleInterceptor();
|
|
const afterSecond = process.stderr.listenerCount("error");
|
|
|
|
assert.equal(afterSecond, afterFirst, "listener count must not grow across reset/re-init cycles");
|
|
assert.ok(afterFirst > baseline, "sanity: init must add a listener");
|
|
});
|
|
|
|
test.after(() => {
|
|
__consoleInterceptorInternals.reset();
|
|
|
|
if (prevLogToFile === undefined) delete process.env.APP_LOG_TO_FILE;
|
|
else process.env.APP_LOG_TO_FILE = prevLogToFile;
|
|
|
|
if (prevLogFilePath === undefined) delete process.env.APP_LOG_FILE_PATH;
|
|
else process.env.APP_LOG_FILE_PATH = prevLogFilePath;
|
|
|
|
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|