Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions packages/testing/realm-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import { afterEach, beforeEach } from 'bun:test'
* 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.
* The infinite-long-poll cases that used to strain even this headroom (the
* gap-replay / scope-close-interrupt drains whose forked fiber's AbortSignal
* unwinding could starve teardown under aggressive contention) no longer run
* here: comms-e5vm.2 moved that LOGIC onto the stub HttpClient + TestClock
* (deterministic, no socket, no forked drain), and the one genuinely-socket
* teardown assertion that remains is self-contained in
* `packages/zulip/scope-teardown.test.ts` with its own `Effect.acquireRelease`
* (comms-4lz5). So every fixture still wired through these hooks is a plain
* request/response realm that starts and stops in a few ms — the 30s is wide
* headroom for contention, not a cure for a teardown-starvation mode that is
* now structurally gone.
*/
export const REALM_HOOK_TIMEOUT_MS = 30_000

Expand Down
108 changes: 108 additions & 0 deletions packages/zulip/scope-teardown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tier-3 residue (comms-4lz5): the **one** assertion in the suite that
* genuinely cannot leave the socket — proving that interrupting an in-flight
* `FetchHttpClient` long-poll on scope close actually tears down the underlying
* TCP connection (`AbortSignal → fetch → socket`).
*
* Everything else the event pump does — gap-replay, 429 retry,
* BAD_EVENT_QUEUE_ID reconnect, the Effect fiber-interrupt LOGIC of scope close
* — moved onto the owned-fake stub HttpClient + TestClock in `adapter-events.test.ts`
* (comms-e5vm.2): deterministic, no socket. The stub proves the fiber unwinds.
* It cannot prove the *socket* unwinds, because there is no socket. That gap is
* this test, and it is the **only surviving `Bun.serve` long-poll** in the suite.
*
* The honest assertion is **server-side**: a real `Bun.serve` whose long-poll
* handler parks forever but listens on the request's `req.signal`. When the
* client tears the connection down, Bun fires that abort. Observing it is proof
* the TCP teardown reached the peer — not merely that the Effect fiber
* unwound on our side. The Effect platform client wires this end to end:
* `httpClient.make` aborts the request's `AbortController` `onInterrupt`, and
* `FetchHttpClient` passes that `signal` into `fetch`.
*
* Lifecycle is `Effect.acquireRelease`/`Scope` throughout: the server is
* acquired with `server.stop(true)` as its release, and the long-poll is forked
* into an inner `Effect.scoped` whose close is the interruption under test — so
* release is guaranteed even on the interruption path.
*/

import { effectTest } from '@commy/testing/effect-test'
import { FetchHttpClient, HttpClient } from '@effect/platform'
import { Data, Duration, Effect } from 'effect'

class ConnectionNotTornDown extends Data.TaggedError('ConnectionNotTornDown')<{
readonly message: string
}> {}

interface LongPollServer {
readonly url: string
/** Resolves once the server has the long-poll request in flight. */
readonly pollInFlight: Promise<void>
/** Resolves when the client tears the in-flight connection down (`req.signal`). */
readonly clientDisconnected: Promise<void>
readonly stop: () => Promise<void>
}

// A real Bun.serve whose /events handler parks forever and reports, via
// req.signal, when the client disconnects mid-poll — the server-side proof of
// TCP teardown.
const startLongPollServer = (): LongPollServer => {
const inFlight = Promise.withResolvers<void>()
const disconnected = Promise.withResolvers<void>()
const server = Bun.serve({
port: 0,
fetch: (req) => {
inFlight.resolve()
return new Promise<Response>((resolve) => {
req.signal.addEventListener('abort', () => {
disconnected.resolve()
resolve(new Response(null, { status: 499 }))
})
})
},
})
if (typeof server.port !== 'number') {
throw new Error('long-poll server failed to bind a TCP port')
}
return {
url: `http://localhost:${server.port}/events`,
pollInFlight: inFlight.promise,
clientDisconnected: disconnected.promise,
stop: () => server.stop(true),
}
}

effectTest(
'closing the scope of an in-flight FetchHttpClient long-poll tears down the TCP connection (comms-4lz5)',
() =>
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(Effect.sync(startLongPollServer), (s) =>
Effect.promise(() => s.stop()),
)
const client = yield* HttpClient.HttpClient

// Fork the real long-poll into an inner scope, hold until the server has
// it in flight on the socket, then close the scope — the interruption is
// what must propagate to the socket.
yield* Effect.scoped(
Effect.gen(function* () {
yield* Effect.forkScoped(client.get(server.url))
yield* Effect.promise(() => server.pollInFlight)
}),
)

// The scope is closed; the forked fiber is interrupted. Proof of teardown
// is the server observing the client's disconnect. A FetchHttpClient that
// failed to abort the socket would leave this parked, and the timeout
// fails the test loudly.
yield* Effect.promise(() => server.clientDisconnected).pipe(
Effect.timeoutFail({
duration: Duration.seconds(3),
onTimeout: () =>
new ConnectionNotTornDown({
message: 'scope close did not tear down the in-flight long-poll connection',
}),
}),
)
}),
{ layer: FetchHttpClient.layer, timeout: 10_000 },
)