mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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.
58 lines
2.6 KiB
TypeScript
58 lines
2.6 KiB
TypeScript
import { annotate } from "./context";
|
|
import { trackBackgroundTask } from "./background-tasks";
|
|
|
|
/**
|
|
* Run a best-effort background promise and leave an observable breadcrumb
|
|
* when it rejects — the disciplined replacement for `.catch(() => {})`, which
|
|
* swallows a background failure with no `annotate()` or logger call.
|
|
*
|
|
* Use it for work whose failure must not fail the request but SHOULD be
|
|
* visible: background enqueues (reminder satisfaction, rollup refresh),
|
|
* re-auth state transitions, and post-write sync side effects.
|
|
*
|
|
* Observability without a control-flow change:
|
|
* - The rejection reason is annotated into the current wide event under a
|
|
* per-action `meta` key, so it flows through the SAME central redaction
|
|
* the event builder applies at emit time — the raw message is never
|
|
* logged unredacted. When the work settles after the event has already
|
|
* flushed (or runs outside a request context, e.g. a pg-boss worker),
|
|
* `annotate()` is a safe no-op.
|
|
* - A payload-free `console.warn` (SWC keeps `warn` in prod) records the
|
|
* action name so a late-settling failure still surfaces in the logs.
|
|
* Only the caller-supplied action label — never the error text — reaches
|
|
* the raw console sink, keeping it redaction-safe by construction.
|
|
*
|
|
* The promise stays fire-and-forget: the caller does not await it and its
|
|
* resolution is discarded exactly as before. Under test the settled handle is
|
|
* registered with `trackBackgroundTask` so the suite setup can await it — see
|
|
* `./background-tasks` for why a late `console.warn` is otherwise a teardown
|
|
* hazard.
|
|
*
|
|
* @param promise The background work. May already be running.
|
|
* @param context.action A stable `<surface>.<noun>.<verb>` label (never user
|
|
* input) identifying which background task failed.
|
|
* @param context.meta Optional extra fields to attach to the breadcrumb; they
|
|
* ride the annotate/redact path with the error message.
|
|
*/
|
|
export function fireAndForget(
|
|
promise: Promise<unknown>,
|
|
context: { action: string; meta?: Record<string, unknown> },
|
|
): void {
|
|
const settled = Promise.resolve(promise).catch((error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
annotate({
|
|
meta: {
|
|
[`fire_and_forget.${context.action}`]: {
|
|
error: message,
|
|
...context.meta,
|
|
},
|
|
},
|
|
});
|
|
// Action label only — the error payload rides the redacted annotate path
|
|
// above, so nothing sensitive reaches the raw console sink.
|
|
console.warn(`[fire-and-forget] background task failed: ${context.action}`);
|
|
});
|
|
|
|
trackBackgroundTask(settled);
|
|
}
|