From 7d018ba4bec12a2725e9a975839fd82d538eccd0 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 4 May 2026 18:15:01 -0400 Subject: [PATCH 1/2] realm-server: poll for port release in test fixture closeServer The cached-template builder for `setupPermissionedRealmsCached` (a) starts a real RealmServer on the realm URL's port, (b) populates the template DB, (c) tears down via closeServer + DB cleanup. Then the actual test's `before` hook starts a *second* RealmServer on the same port via the same fixture plumbing. `server.close(cb)` only stops accepting new connections; the kernel still holds the bind slot briefly, and the next listen() races into EADDRINUSE. Locally this reproduces on the very first prerendering test as `EADDRINUSE :::4455`. Add `awaitPortRelease(host, port, { timeoutMs })` and call it from `closeServer` after the existing close path (idle/all connections + close callback). It opens a TCP probe and waits for ECONNREFUSED, with a 2s ceiling and a clear diagnostic warning on timeout so the next failure points at the leaked port rather than the downstream EADDRINUSE. Per-cycle port assignment for the builder (so it could bind a different port from the test) was considered but ruled out: `boxel_index` rows are keyed by realm_url in the primary key, so changing the URL during build would invalidate the template DB the test reads back. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/realm-server/tests/helpers/index.ts | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index c0cd0e7683..57a7da0a1f 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -60,6 +60,7 @@ import { PgQueueRunner, } from '@cardstack/postgres'; import type { Server } from 'http'; +import { Socket as NetSocket } from 'net'; import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import { Prerenderer as LocalPrerenderer, @@ -499,6 +500,19 @@ export async function closeServer(server: Server) { if (!server) { return; } + // Capture the listening address before close() so we can poll the OS until + // the port is fully unbound. node's `server.close(cb)` only waits for the + // listener to stop accepting new connections — under load, the kernel can + // hold the port in TIME_WAIT briefly and the next bind() races into + // EADDRINUSE. + let address = server.address(); + let host: string | undefined; + let port: number | undefined; + if (address && typeof address === 'object') { + host = address.address; + port = address.port; + } + // Force-close idle keep-alive sockets so server.close() resolves promptly. // Without this, a lingering connection from the host page (puppeteer fetching // from the realm server) can hold the port bound long after the test moves @@ -506,6 +520,70 @@ export async function closeServer(server: Server) { server.closeIdleConnections?.(); server.closeAllConnections?.(); await new Promise((r) => server.close(() => r())); + + if (host && typeof port === 'number' && port > 0) { + await awaitPortRelease(host, port); + } +} + +/** + * Poll a TCP port on `host` until a fresh connect() is refused (i.e. nothing + * is LISTENing there anymore). Used after `server.close()` returns to give + * the kernel a chance to fully release the bind slot before the next fixture + * tries to listen on the same port. + * + * Resolves on first refusal. Logs a clear diagnostic on timeout so the next + * failure points to the leaked port rather than the downstream EADDRINUSE. + */ +export async function awaitPortRelease( + host: string, + port: number, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + let timeoutMs = options.timeoutMs ?? 2000; + let intervalMs = options.intervalMs ?? 25; + // Map the wildcard bind address back to a connectable loopback address. + // Server.address() reports `::` for IPv6-any, `0.0.0.0` for IPv4-any — + // neither is a valid connect target. + let connectHost = host; + if (host === '::' || host === '0.0.0.0') { + connectHost = '127.0.0.1'; + } + + let started = Date.now(); + while (Date.now() - started < timeoutMs) { + let stillListening = await new Promise((resolve) => { + let socket = new NetSocket(); + let settled = false; + let done = (listening: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(listening); + }; + socket.setTimeout(Math.max(50, intervalMs * 2)); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(true)); + socket.once('error', () => { + // ECONNREFUSED is the expected signal that the port is fully released. + // Anything else (host unreachable, etc.) we also treat as released — + // we're not the right place to diagnose upstream network errors and + // a non-listening socket is a non-listening socket. + done(false); + }); + socket.connect(port, connectHost); + }); + + if (!stillListening) { + return; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + + console.warn( + `awaitPortRelease: ${connectHost}:${port} still appears bound after ${timeoutMs}ms; ` + + `the next fixture binding this port will likely EADDRINUSE.`, + ); } function trackServer(server: Server): Server { From 0aba64c75b973d0cb96025edb01e6c04d1ffcdb5 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 4 May 2026 19:04:15 -0400 Subject: [PATCH 2/2] realm-server: probe IPv6 wildcard sockets via IPv6 loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review caught: when server.address().address is '::' (IPv6 wildcard), probing 127.0.0.1 can falsely report the port as released on systems with IPv6-only binding behavior — the IPv4 probe gets ECONNREFUSED while the IPv6 listener is still bound. Map '::' to '::1' instead so the probe runs in the same address family as the listener. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/realm-server/tests/helpers/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 57a7da0a1f..844e3febe0 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -544,9 +544,14 @@ export async function awaitPortRelease( let intervalMs = options.intervalMs ?? 25; // Map the wildcard bind address back to a connectable loopback address. // Server.address() reports `::` for IPv6-any, `0.0.0.0` for IPv4-any — - // neither is a valid connect target. + // neither is a valid connect target. Probe in the same address family the + // listener was bound to: if we map `::` to `127.0.0.1` and the system has + // IPv6-only binding behavior, the IPv4 probe gets ECONNREFUSED while the + // original IPv6 listener is still bound, falsely reporting release. let connectHost = host; - if (host === '::' || host === '0.0.0.0') { + if (host === '::') { + connectHost = '::1'; + } else if (host === '0.0.0.0') { connectHost = '127.0.0.1'; }