mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-10 00:42:21 +03:00
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) Two shards on release/v3.8.51 went red in one day with the same signature — "ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only .github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass alone and on re-run: the cleanup races something still writing into the directory (SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner the window opens. 1154 test files do their own cleanup with fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries. One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record): every rm / rmSync / rmdirSync option object with `recursive: true` and no `maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292 files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included. Only the option object changes: no call site, assertion or import is touched. Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292 files; a random 20-file sample runs green (quota-redis-store hangs identically on the untouched tree — it needs a Redis on localhost, an environment matter). The four unit shards on this PR are the full run. * fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff The gate shells out to `git diff` through execFileSync with Node's default 1 MB maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing anything. 64 MB is far above any real PR and costs nothing when unused.
265 lines
11 KiB
TypeScript
265 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 });
|
|
});
|