mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
/**
|
|
* The registry of detached background work, so a test can await what a
|
|
* request-path shortcut deliberately does not.
|
|
*
|
|
* ## The failure this exists for
|
|
*
|
|
* Several code paths start a promise and return without awaiting it, then log
|
|
* on the detached handle so the failure is not swallowed — `fireAndForget`,
|
|
* the stale-while-revalidate cache refresh, the offline query persister. That
|
|
* is right for production and hostile to a test runner: vitest replaces
|
|
* `globalThis.console` with a sink that forwards every write to the main
|
|
* thread as an `onUserConsoleLog` RPC call, and when a worker finishes a test
|
|
* file it awaits the calls in flight and then REJECTS whatever was started
|
|
* after that point. A detached write that lands in the window between those
|
|
* two steps fails the entire run with
|
|
*
|
|
* EnvironmentTeardownError: [vitest-worker]: Closing rpc while
|
|
* "onUserConsoleLog" was pending
|
|
*
|
|
* blamed on the file the worker was tearing down — which need not contain a
|
|
* single console call of its own, and never points at the test that started
|
|
* the work. Muting the console in tests would hide the log line the detached
|
|
* path exists to produce and leave the race in place; awaiting the work
|
|
* removes the precondition.
|
|
*
|
|
* ## Why the registry is test-only
|
|
*
|
|
* Production never awaits it, so tracking there would buy nothing and cost
|
|
* something real: a strong reference to every background promise for as long
|
|
* as it stays unsettled. One wedged task on a long-lived pg-boss worker would
|
|
* then be retained for the life of the process, which turns an observability
|
|
* affordance into a slow leak. Under test the worker is short-lived and the
|
|
* set is drained after every test, so neither concern applies. Outside test
|
|
* every call site keeps exactly the behaviour and cost it had before — start
|
|
* the promise, discard the handle, one boolean check.
|
|
*
|
|
* The module deliberately imports nothing: it is reachable from client code
|
|
* (`lib/pwa/query-persister.ts`) as well as from the server tree, and must not
|
|
* drag `node:async_hooks` into the browser bundle the way `./context` would.
|
|
*/
|
|
const TRACK_IN_FLIGHT =
|
|
typeof process !== "undefined" &&
|
|
(process.env?.VITEST === "true" || process.env?.NODE_ENV === "test");
|
|
|
|
const inFlight = new Set<Promise<unknown>>();
|
|
|
|
/**
|
|
* Register a detached promise so `settleBackgroundTasks()` can await it.
|
|
*
|
|
* Pass a handle that CANNOT reject — the `.catch(...)` result, not the raw
|
|
* work. A rejecting handle would surface as an unhandled rejection through the
|
|
* bookkeeping below, which is a louder signal than this helper should invent.
|
|
*
|
|
* A no-op outside test.
|
|
*/
|
|
export function trackBackgroundTask(settled: Promise<unknown>): void {
|
|
if (!TRACK_IN_FLIGHT) return;
|
|
|
|
inFlight.add(settled);
|
|
void settled.finally(() => {
|
|
inFlight.delete(settled);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Await every registered task still in flight, including work that a settling
|
|
* task starts itself.
|
|
*
|
|
* Both suite setups call this from `afterEach`, so no test can leave
|
|
* background work running past its own end. A no-op outside test, where
|
|
* nothing registers.
|
|
*/
|
|
export async function settleBackgroundTasks(): Promise<void> {
|
|
while (inFlight.size > 0) {
|
|
await Promise.allSettled([...inFlight]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How many tasks are registered right now. Test-only introspection for the
|
|
* registration guard, which asserts both that a task registers and that the
|
|
* set drains back to empty — a retained reference would otherwise pass
|
|
* unnoticed.
|
|
*/
|
|
export function pendingBackgroundTaskCount(): number {
|
|
return inFlight.size;
|
|
}
|