mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
1e4b30c417
commit
bdf3ffb5da
8 files changed
+359
-29
No files matched your search
Vendored
+16
-9
@@ -35,6 +35,7 @@
|
||||
* reaching into the private `Map`.
|
||||
*/
|
||||
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import type { WorkoutsProjection } from "@/lib/workouts/list-read";
|
||||
|
||||
export interface ServerCacheOptions<T = unknown> {
|
||||
@@ -416,15 +417,21 @@ export class ServerCache<T> {
|
||||
// console.error stands in for the wide-event annotation; the
|
||||
// key is logged as its djb2 hash, matching the cache-outcome
|
||||
// meta convention, so the userId segment never reaches stdout.
|
||||
refresh.catch((err) => {
|
||||
console.error(
|
||||
`cache.${this.opts.name ?? "unnamed"}.refresh_failed`,
|
||||
JSON.stringify({
|
||||
key_hash: hashCacheKey(key),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Registered so the test suite can await it: the error below is a
|
||||
// console write on a detached handle, and one that lands while a
|
||||
// vitest worker is closing its RPC channel fails the whole run.
|
||||
// No-op outside test.
|
||||
trackBackgroundTask(
|
||||
refresh.catch((err) => {
|
||||
console.error(
|
||||
`cache.${this.opts.name ?? "unnamed"}.refresh_failed`,
|
||||
JSON.stringify({
|
||||
key_hash: hashCacheKey(key),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { value: cachedEntry.value, outcome: "stale" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { annotate } from "./context";
|
||||
import { trackBackgroundTask } from "./background-tasks";
|
||||
|
||||
/**
|
||||
* Run a best-effort background promise and leave an observable breadcrumb
|
||||
@@ -22,7 +23,10 @@ import { annotate } from "./context";
|
||||
* 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.
|
||||
* 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
|
||||
@@ -34,7 +38,7 @@ export function fireAndForget(
|
||||
promise: Promise<unknown>,
|
||||
context: { action: string; meta?: Record<string, unknown> },
|
||||
): void {
|
||||
void Promise.resolve(promise).catch((error: unknown) => {
|
||||
const settled = Promise.resolve(promise).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -48,4 +52,6 @@ export function fireAndForget(
|
||||
// above, so nothing sensitive reaches the raw console sink.
|
||||
console.warn(`[fire-and-forget] background task failed: ${context.action}`);
|
||||
});
|
||||
|
||||
trackBackgroundTask(settled);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
|
||||
|
||||
@@ -283,20 +284,25 @@ export function startPersistingQueryCache(
|
||||
return;
|
||||
}
|
||||
|
||||
void idbSet(payload).catch((err) => {
|
||||
// Don't silently swallow a full-disk failure: a quota error means the
|
||||
// offline cache stopped updating, and a clear signal beats a stale
|
||||
// snapshot the user can't explain. Drop the persisted blob so the next
|
||||
// flush has room rather than failing again against a full store.
|
||||
if (isQuotaError(err)) {
|
||||
console.warn(
|
||||
"[query-persister] IndexedDB quota exceeded; dropping persisted cache",
|
||||
);
|
||||
void clearPersistedQueryCache();
|
||||
return;
|
||||
}
|
||||
console.warn("[query-persister] persist failed", err);
|
||||
});
|
||||
// Registered so the test suite can await it: the warns below are console
|
||||
// writes on a detached handle, and one that lands while a vitest worker is
|
||||
// closing its RPC channel fails the whole run. No-op outside test.
|
||||
trackBackgroundTask(
|
||||
idbSet(payload).catch((err) => {
|
||||
// Don't silently swallow a full-disk failure: a quota error means the
|
||||
// offline cache stopped updating, and a clear signal beats a stale
|
||||
// snapshot the user can't explain. Drop the persisted blob so the next
|
||||
// flush has room rather than failing again against a full store.
|
||||
if (isQuotaError(err)) {
|
||||
console.warn(
|
||||
"[query-persister] IndexedDB quota exceeded; dropping persisted cache",
|
||||
);
|
||||
void clearPersistedQueryCache();
|
||||
return;
|
||||
}
|
||||
console.warn("[query-persister] persist failed", err);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export async function afterMeasurementMutation(
|
||||
@@ -48,8 +49,13 @@ export async function afterMeasurementMutation(
|
||||
// the rollup leg above failed, so the two legs never gate each other.
|
||||
const types = Array.from(new Set(keys.map((k) => k.type)));
|
||||
if (types.length > 0) {
|
||||
invalidateStatusInsightsForTypes(userId, types).catch((err) => {
|
||||
console.warn(`[${label}] status-insight invalidate failed`, err);
|
||||
});
|
||||
// Registered so the test suite can await it: the warn below is a console
|
||||
// write on a detached handle, and one that lands while a vitest worker is
|
||||
// closing its RPC channel fails the whole run. No-op outside test.
|
||||
trackBackgroundTask(
|
||||
invalidateStatusInsightsForTypes(userId, types).catch((err) => {
|
||||
console.warn(`[${label}] status-insight invalidate failed`, err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user