From 2eb38738df6804357c6b318775c47ab73ea41886 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:54:04 +0100 Subject: [PATCH] Dedup test-realm lifecycle hooks + raise hook timeout for contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-live suite intermittently failed on cache-miss runs (comms-xwqm): under a full-parallel `bun run check` the starved event loop stretched real-in-process-realm setup/teardown past bun's default 5s HOOK timeout — independent of the per-test body timeout the gap-replay test already raised — so the failure landed on whichever test the slow hook wrapped, explaining the moving test name. Extract one shared `registerRealmHooks(start, assign)` + `REALM_HOOK_TIMEOUT_MS` (packages/testing/realm-hooks.ts). The byte-identical realm beforeEach/afterEach boilerplate in adapter.test.ts and bot-dm-guard.test.ts, the same-shaped fixture hooks in http.test.ts, and the contract suite's factory/dispose hooks all route through it. The `assign` callback hands the instance back to each file's own `let` binding, so the ~170 `realm.`/`fixture.` call sites stay untouched. contract.ts (async factory / Effect dispose) just imports the timeout constant. This is headroom, not a full cure: the two infinite-long-poll teardown tests (gap-replay comms-4au, scope-close-interrupt spj3.8) can still exceed even 30s under aggressive contention. That residual is a fiber/scope lifecycle problem (server.stop(true) is not the blocker — an isolated probe resolves it in 0.2ms with a handler stuck forever), tracked for the Effect-Scope-based realm rework in comms-4lz5 / comms-30hq. --- packages/testing/contract.ts | 5 ++-- packages/testing/realm-hooks.ts | 39 +++++++++++++++++++++++++++++ packages/zulip/adapter.test.ts | 11 +++----- packages/zulip/bot-dm-guard.test.ts | 11 +++----- packages/zulip/http.test.ts | 11 +++----- 5 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 packages/testing/realm-hooks.ts diff --git a/packages/testing/contract.ts b/packages/testing/contract.ts index c43dd5a..de56d06 100644 --- a/packages/testing/contract.ts +++ b/packages/testing/contract.ts @@ -32,6 +32,7 @@ import { UnknownIdentity, } from '@commy/core/ports' import { Duration, Effect, Exit, Option, Queue, type Scope, Stream } from 'effect' +import { REALM_HOOK_TIMEOUT_MS } from './realm-hooks.ts' export interface ContractEnv { /** @@ -110,9 +111,9 @@ export const runAgentCommsContract = (label: string, factory: ContractFactory): beforeEach(async () => { env = await factory() - }) + }, REALM_HOOK_TIMEOUT_MS) - afterEach(() => Effect.runPromise(env.dispose())) + afterEach(() => Effect.runPromise(env.dispose()), REALM_HOOK_TIMEOUT_MS) test('publisher.post returns a MessageRef whose channel matches the destination', () => Effect.runPromise( diff --git a/packages/testing/realm-hooks.ts b/packages/testing/realm-hooks.ts new file mode 100644 index 0000000..82f3f79 --- /dev/null +++ b/packages/testing/realm-hooks.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach } from 'bun:test' + +/** + * bun:test lifecycle hooks keep their own 5s timeout, independent of any + * per-test timeout. In isolation a real in-process realm starts and stops in a + * few ms, but under a full-parallel `bun run check` the starved event loop can + * stretch that setup/teardown past 5s — and then the HOOK, not the test body, + * times out, landing the failure on whichever test the hook happened to be + * wrapping (comms-xwqm). 30s gives that contention wide headroom while still + * failing if a hook genuinely wedges. + * + * This is headroom, not a cure-all: the long-poll teardown tests (the + * gap-replay / scope-close-interrupt cases that drive an infinite server + * handler and rely on AbortSignal unwinding a forked drain fiber) can still + * exceed even this under aggressive contention. That residual is a fiber/scope + * lifecycle problem, not a too-small-number problem, and is tracked for the + * Effect-Scope-based realm rework (comms-4lz5 / comms-30hq) — running the whole + * test, realm acquisition included, inside one scope with guaranteed finalizers + * removes the leftover-fiber-starves-teardown failure mode structurally. + */ +export const REALM_HOOK_TIMEOUT_MS = 30_000 + +/** + * Register per-test realm lifecycle on the bun:test hooks — a fresh instance + * before each test, stopped after — with the contention-proof timeout above. + * The caller keeps its own directly-referenced binding via `assign`, so + * existing `realm.` / `fixture.` call sites stay untouched. + */ +export const registerRealmHooks = Promise }>( + start: () => T, + assign: (instance: T) => void, +): void => { + let instance: T + beforeEach(() => { + instance = start() + assign(instance) + }, REALM_HOOK_TIMEOUT_MS) + afterEach(() => instance.stop(), REALM_HOOK_TIMEOUT_MS) +} diff --git a/packages/zulip/adapter.test.ts b/packages/zulip/adapter.test.ts index d63c371..39760d1 100644 --- a/packages/zulip/adapter.test.ts +++ b/packages/zulip/adapter.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { expect, test } from 'bun:test' import type { ChannelRef, Identity, InboundEvent, MessageRef } from '@commy/core/ports' import { DirectoryError, @@ -17,6 +17,7 @@ import { UnknownChannel, type UnknownIdentity, } from '@commy/core/ports' +import { registerRealmHooks } from '@commy/testing/realm-hooks' import { FetchHttpClient, HttpClient } from '@effect/platform' import { Cause, Duration, Effect, Exit, Option, Queue, Redacted, type Scope, Stream } from 'effect' import type { ZulipAdapter, ZulipAdapterConfig } from './adapter.ts' @@ -27,12 +28,8 @@ import { startTestRealm } from './test-server.ts' let realm: TestRealm -beforeEach(() => { - realm = startTestRealm() -}) - -afterEach(async () => { - await realm.stop() +registerRealmHooks(startTestRealm, (next) => { + realm = next }) const HERMES = { diff --git a/packages/zulip/bot-dm-guard.test.ts b/packages/zulip/bot-dm-guard.test.ts index cd816a8..958da2f 100644 --- a/packages/zulip/bot-dm-guard.test.ts +++ b/packages/zulip/bot-dm-guard.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { expect, test } from 'bun:test' +import { registerRealmHooks } from '@commy/testing/realm-hooks' import { FetchHttpClient, HttpClient } from '@effect/platform' import { Cause, Effect, Exit, Option, Schema } from 'effect' import { BotToBotDirectMessageError, type RecipientDirectory, wrapBotHttp } from './bot-dm-guard.ts' @@ -9,12 +10,8 @@ import { ZulipUserRef } from './user-ref.ts' let realm: TestRealm -beforeEach(() => { - realm = startTestRealm() -}) - -afterEach(async () => { - await realm.stop() +registerRealmHooks(startTestRealm, (next) => { + realm = next }) const SELF_ID = ZulipUserRef(100) diff --git a/packages/zulip/http.test.ts b/packages/zulip/http.test.ts index 33fdf8a..7a0d80a 100644 --- a/packages/zulip/http.test.ts +++ b/packages/zulip/http.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { expect, test } from 'bun:test' +import { registerRealmHooks } from '@commy/testing/realm-hooks' import { FetchHttpClient, HttpClient } from '@effect/platform' import { Cause, @@ -115,12 +116,8 @@ const startFixture = (): Fixture => { let fixture: Fixture -beforeEach(() => { - fixture = startFixture() -}) - -afterEach(async () => { - await fixture.stop() +registerRealmHooks(startFixture, (next) => { + fixture = next }) const successSchema = Schema.Struct({ result: Schema.Literal('success') })