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') })