|
| 1 | +// Contract: the postgres pool finalizer must AWAIT the connection teardown. |
| 2 | +// |
| 3 | +// The cloud MCP auth seam (`makeMcpOrganizationAuthServices`) builds a fresh |
| 4 | +// postgres pool on EVERY `/mcp` request and closes it in its `acquireRelease` |
| 5 | +// finalizer. That finalizer used to be fire-and-forget (`Effect.runFork( |
| 6 | +// sql.end({ timeout: 0 }))`): it returned before the socket was torn down, so |
| 7 | +// under sustained MCP load closed-but-unreaped sockets piled up against the |
| 8 | +// dev PGlite server (effectively single-connection) faster than it reaped |
| 9 | +// them. New connects queued behind the backlog, request latency climbed into |
| 10 | +// the tens of seconds, and the e2e cloud dev stack hung after a few minutes: |
| 11 | +// the CI cascade flake. |
| 12 | +// |
| 13 | +// These tests characterize the contract the old fire-and-forget close violated |
| 14 | +// (`closePostgres` itself is new alongside them): it must (a) call `sql.end` |
| 15 | +// with a NON-zero drain window (a clean Terminate, not an abandon) and (b) |
| 16 | +// return an Effect that does not complete until `sql.end` has resolved, even |
| 17 | +// when the teardown takes real wall-clock time. All asserted with a fake `sql` |
| 18 | +// whose `end()` completion is observable, so the tests are fast and need no |
| 19 | +// live database. |
| 20 | + |
| 21 | +import { describe, expect, it } from "@effect/vitest"; |
| 22 | +import { Effect, Exit } from "effect"; |
| 23 | + |
| 24 | +import { POSTGRES_END_TIMEOUT_SECONDS, closePostgres } from "./db"; |
| 25 | + |
| 26 | +describe("closePostgres", () => { |
| 27 | + it.effect("passes a non-zero drain window to sql.end (clean Terminate, not abandon)", () => |
| 28 | + Effect.gen(function* () { |
| 29 | + let received: { timeout?: number } | undefined; |
| 30 | + const fakeSql = { |
| 31 | + end: (options?: { timeout?: number }) => { |
| 32 | + received = options; |
| 33 | + return Promise.resolve(); |
| 34 | + }, |
| 35 | + }; |
| 36 | + |
| 37 | + yield* closePostgres(fakeSql); |
| 38 | + |
| 39 | + expect(received?.timeout).toBe(POSTGRES_END_TIMEOUT_SECONDS); |
| 40 | + expect(POSTGRES_END_TIMEOUT_SECONDS).toBeGreaterThan(0); |
| 41 | + }), |
| 42 | + ); |
| 43 | + |
| 44 | + it.effect("does not complete until sql.end resolves (awaits the teardown)", () => |
| 45 | + Effect.gen(function* () { |
| 46 | + // `end` records an ordering marker only after an async tick. If |
| 47 | + // `closePostgres` awaits it, the "close completed" marker lands AFTER the |
| 48 | + // "end resolved" marker. The old fire-and-forget close returned before |
| 49 | + // `end` ran, so "close completed" would land FIRST. |
| 50 | + const order: string[] = []; |
| 51 | + const fakeSql = { |
| 52 | + end: () => |
| 53 | + // Defer resolution across a microtask so a non-awaiting close would |
| 54 | + // observably finish before this runs. |
| 55 | + Promise.resolve() |
| 56 | + .then(() => Promise.resolve()) |
| 57 | + .then(() => { |
| 58 | + order.push("end-resolved"); |
| 59 | + }), |
| 60 | + }; |
| 61 | + |
| 62 | + yield* closePostgres(fakeSql); |
| 63 | + order.push("close-completed"); |
| 64 | + |
| 65 | + // Awaiting the teardown means end resolved strictly before close returned. |
| 66 | + expect(order).toEqual(["end-resolved", "close-completed"]); |
| 67 | + }), |
| 68 | + ); |
| 69 | + |
| 70 | + it.effect("awaits a teardown that takes real wall-clock time (bounded by the ceiling)", () => |
| 71 | + Effect.gen(function* () { |
| 72 | + // `end` resolves only after a real timer delay, not just a microtask. |
| 73 | + // This pins the "we await to completion, the timeout is only a ceiling" |
| 74 | + // contract: closePostgres must stay suspended across the delay rather |
| 75 | + // than resolving early. |
| 76 | + const order: string[] = []; |
| 77 | + const fakeSql = { |
| 78 | + end: () => |
| 79 | + new Promise<void>((resolve) => { |
| 80 | + setTimeout(() => { |
| 81 | + order.push("end-resolved"); |
| 82 | + resolve(); |
| 83 | + }, 75); |
| 84 | + }), |
| 85 | + }; |
| 86 | + |
| 87 | + const startedAt = Date.now(); |
| 88 | + yield* closePostgres(fakeSql); |
| 89 | + order.push("close-completed"); |
| 90 | + |
| 91 | + expect(order).toEqual(["end-resolved", "close-completed"]); |
| 92 | + // Slack of a few ms: platform timers may fire marginally early. |
| 93 | + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(70); |
| 94 | + }), |
| 95 | + ); |
| 96 | + |
| 97 | + it.effect("swallows sql.end failures (a teardown error must not fail the request scope)", () => |
| 98 | + Effect.gen(function* () { |
| 99 | + const fakeSql = { |
| 100 | + // A rejected teardown (connection already gone) must not surface as a |
| 101 | + // scope failure. |
| 102 | + // oxlint-disable-next-line executor/no-promise-reject -- test fake: model `sql.end` (a raw postgres.js promise) rejecting |
| 103 | + end: () => new Promise<void>((_resolve, reject) => reject("connection already gone")), |
| 104 | + }; |
| 105 | + const exit = yield* Effect.exit(closePostgres(fakeSql)); |
| 106 | + expect(Exit.isSuccess(exit)).toBe(true); |
| 107 | + }), |
| 108 | + ); |
| 109 | +}); |
0 commit comments