Files
HealthLog/src/__tests__/background-task-settle-guard.test.ts
T
Marc-André Bombeck bdf3ffb5da test: settle detached background work before a test file tears down
The unit suite reported all 1937 files green and then failed the job with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
pending`, naming a different scoring test each time.

Vitest replaces globalThis.console with a sink that forwards every write
to the main thread as an onUserConsoleLog RPC call. When a worker
finishes a test file it awaits the calls in flight and then rejects
whatever was started after that point, so a console write from work that
outlived its test lands in the gap and fails the run. The blame goes to
the file being torn down rather than to the test that started the work,
which is why the named file never logged anything itself.

Three paths detach a promise and log on the handle: fireAndForget, the
stale-while-revalidate cache refresh, and the offline query persister.
They now register the handled handle in a set that both suite setups
drain in afterEach, so no test leaves work in flight. The registry is
inert outside test: nothing there awaits it, and holding a reference to
an unsettled promise would retain it for the life of a worker. No
production behaviour changes, so there is no changelog entry.

Evidence. A focused harness that starts failing background tasks and
returns hit the teardown error 10 runs out of 10 before the change and
0 out of 10 after. Instrumenting the real suite showed two detached
console writes per run escaping their test, both from the rollup tail;
after the change, none, and thirteen writes per run now complete inside
the test that started them.

The guard asserts the behaviour, not a count: a registered task must
keep settleBackgroundTasks from resolving ahead of it, and the set must
drain back to empty. It goes red on unregistered work, on a disabled
registry, on an unwrapped call site, on a sweep that matches nothing,
and on a setup that drops the hook.
2026-09-03 05:30:08 +02:00

179 lines
6.5 KiB
TypeScript

/**
* Background work started inside a test must not outlive it.
*
* Several paths in this codebase start a promise, return without awaiting it,
* and log on the detached handle so the failure is not swallowed. Vitest
* replaces `globalThis.console` with a sink that forwards every write to the
* main thread as an `onUserConsoleLog` RPC call, awaits the calls in flight
* when it tears a test file down, and then REJECTS whatever was started after
* that point. A detached write that lands in that window fails the whole run
* with `EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
* pending` — blamed on the file the worker was tearing down, which is not the
* test that started the work and need not contain a console call of its own.
*
* Both suite setups therefore await `settleBackgroundTasks()` after every
* test, and that only works while the detached paths keep registering what
* they start.
*
* These are tripwires, not proofs. The sweep below matches a detached
* `.catch()` / `.then()` whose handler writes to the console — the shape that
* actually produced the failure. A background path that logs some other way,
* or that logs from a callee rather than from the handler, is invisible to it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import {
pendingBackgroundTaskCount,
settleBackgroundTasks,
trackBackgroundTask,
} from "@/lib/logging/background-tasks";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
/**
* Detached console writers that deliberately stay unregistered.
*
* `cli/mcp-stdio.ts` is a process entrypoint: its `main().catch(...)` is the
* top-level failure handler and there is no test that runs it, so there is
* nothing for a registry to hold. `jobs/boss-instance.ts` awaits its handle —
* the matcher sees the `console` call in the following lines, not a detached
* promise.
*/
const UNREGISTERED_BY_DESIGN = new Set([
"cli/mcp-stdio.ts",
"lib/jobs/boss-instance.ts",
]);
/** Every non-test source file under `src/`, minus the generated Prisma client. */
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"));
}
/** Lines starting a `.catch()` / `.then()` whose handler writes to the console. */
function consoleWritingHandlers(): { file: string; line: number }[] {
const found: { file: string; line: number }[] = [];
for (const rel of sourceFiles()) {
const lines = readFileSync(join(SRC, rel), "utf8").split("\n");
lines.forEach((line, index) => {
if (!/\.(catch|then)\(/.test(line)) return;
const handler = lines.slice(index, index + 8).join("\n");
if (!/console\.(log|warn|error|info|debug|trace)\(/.test(handler)) return;
found.push({ file: rel, line: index + 1 });
});
}
return found;
}
describe("background-task settle contract", () => {
it("registers an in-flight task and drains it on settle", async () => {
let release: () => void = () => {};
const task = new Promise<void>((resolve) => {
release = resolve;
});
trackBackgroundTask(task);
expect(pendingBackgroundTaskCount()).toBe(1);
let settled = false;
const settling = settleBackgroundTasks().then(() => {
settled = true;
});
// Settle must not resolve ahead of the task it is waiting on — that is the
// whole reason the registry exists.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
release();
await settling;
expect(settled).toBe(true);
// And nothing is retained once the task is done.
expect(pendingBackgroundTaskCount()).toBe(0);
});
it("registers what fireAndForget starts", async () => {
let release: () => void = () => {};
fireAndForget(
new Promise<void>((resolve) => {
release = resolve;
}),
{ action: "guard.background.registered" },
);
expect(pendingBackgroundTaskCount()).toBe(1);
release();
await settleBackgroundTasks();
expect(pendingBackgroundTaskCount()).toBe(0);
});
it("also awaits work a settling task starts itself", async () => {
let innerDone = false;
fireAndForget(
Promise.resolve().then(() => {
fireAndForget(
new Promise<void>((resolve) =>
setTimeout(() => {
innerDone = true;
resolve();
}, 10),
),
{ action: "guard.background.cascade.inner" },
);
}),
{ action: "guard.background.cascade.outer" },
);
await settleBackgroundTasks();
expect(innerDone).toBe(true);
expect(pendingBackgroundTaskCount()).toBe(0);
});
it("keeps every detached console writer registered", () => {
const sites = consoleWritingHandlers();
// An empty sweep must not read as a pass: if the matcher stops finding the
// shape it is the matcher that broke, not the tree that got clean. Pinned
// below the five sites that exist today, so removing one legitimately does
// not trip a guard that is about the sweep being alive, not about freezing
// a count.
expect(sites.length).toBeGreaterThanOrEqual(3);
const unregistered = sites.filter(({ file, line }) => {
if (UNREGISTERED_BY_DESIGN.has(file)) return false;
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
// The registration wraps the handle, so it opens on one of the few lines
// above the `.catch(` / `.then(` the sweep matched.
const preamble = lines.slice(Math.max(0, line - 5), line).join("\n");
return !/trackBackgroundTask\(/.test(preamble);
});
expect(
unregistered.map(({ file, line }) => `src/${file}:${line}`),
"a detached promise logs to the console without registering — wrap the " +
"handled handle in trackBackgroundTask() so the suite can await it",
).toEqual([]);
});
it("keeps the settle hook wired into both suite setups", () => {
for (const setup of [
"vitest.setup.ts",
join("tests", "integration", "environment-setup.ts"),
]) {
const source = readFileSync(join(ROOT, setup), "utf8");
expect(
source.includes("settleBackgroundTasks"),
`${setup} no longer awaits background tasks after each test`,
).toBe(true);
}
});
});