From 27937527df61a39dd964ae089aa666e231e428a9 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Thu, 30 Jul 2026 17:49:57 -0400 Subject: [PATCH 1/6] docs: add repository AGENTS.md guidelines --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a5e43d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository Guidelines for WebJamSocketCluster + +## TypeScript & Type Safety +- **No `any`**: `@typescript-eslint/no-explicit-any` is set to `'error'`. Do not disable this rule or use `: any` or `as any`. +- **Shared Domain Types**: Centralized types for socket connections, streams, payloads, and domain objects live in `src/types/index.ts`. +- **Mongoose Generic Facade**: Model facades extend `Facade` defined in `src/lib/facade.ts`. When typing generic model methods, use double type assertions (e.g. `(await ... as unknown) as T[]`) to satisfy Mongoose generic method signatures without using `any`. +- **SocketCluster Mocks**: When mocking `AGServer` or `IClient` in tests, cast stub objects using `as unknown as socketClusterServer.AGServer` or `as unknown as IClient`. Ensure `receiver.next()` mocks return `{ value?: T; done?: boolean }`. From 01f796ba27d0ef7b6b245fd329b95fae5514bf26 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Sat, 1 Aug 2026 16:47:39 -0400 Subject: [PATCH 2/6] Fix detached listener crash in AgController.handleDisconnect (v3.0.14) client.listener/client.socket.listener was pulled off its owner into a bare variable and invoked standalone, so async-stream-emitter's listener() ran with no receiver. In production, ConnectionData never exposes client.listener directly, so the client.socket?.listener branch was live on every incoming socket connection, and the detached call threw "Cannot read properties of undefined (reading 'stream')" inside handleDisconnect (via sendPulse -> addSocket -> handleConnections), crash-looping the webjamsocket Heroku app and taking down joshandmariamusic.com (503) and the gigs/images feed on web-jam.com/music. Call listener() as a method of its owning object (client or client.socket) so `this` stays bound, preserving the existing client.listener -> client.socket.listener fallback and early-return. --- package-lock.json | 4 ++-- package.json | 2 +- src/AgController/index.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d19399..c057950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webjamsocketserver", - "version": "3.0.12", + "version": "3.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webjamsocketserver", - "version": "3.0.12", + "version": "3.0.14", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index dc58fba..a7845c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webjamsocketserver", "description": "Uses latest version of socketcluster-server", - "version": "3.0.13", + "version": "3.0.14", "license": "MIT", "type": "module", "main": "build/src/index.js", diff --git a/src/AgController/index.ts b/src/AgController/index.ts index 7b32c07..8531a8c 100644 --- a/src/AgController/index.ts +++ b/src/AgController/index.ts @@ -49,9 +49,9 @@ class AgController { handleDisconnect(client: IClient, interval: NodeJS.Timeout):void { void (async () => { let disconnect: { value?: undefined; done?: boolean }; - const listener = client.listener ?? client.socket?.listener; - if (!listener) return; - const dConsumer = listener('disconnect').createConsumer(); + const target = client.listener ? client : client.socket; + if (!target?.listener) return; + const dConsumer = target.listener('disconnect').createConsumer(); while (true) { disconnect = await dConsumer.next() as { value?: undefined; done?: boolean }; clearInterval(interval); From 10879d100f94d2cadf7543f959702e0b0b0f3a1b Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Sat, 1 Aug 2026 16:47:53 -0400 Subject: [PATCH 3/6] Add regression test: handleDisconnect must call listener as a method Fake client stubs elsewhere in this file use a plain arrow/function for `listener`, which doesn't care about its receiver, so they didn't catch the detached-call bug. This test asserts `this` inside `socket.listener(...)` is bound to the socket object, matching a real async-stream-emitter receiver. Confirmed it fails against the pre-fix code (capturedThis undefined) and passes against the fix (capturedThis === socketOwner). --- test/AgController/index.spec.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/AgController/index.spec.ts b/test/AgController/index.spec.ts index 2f50c14..8604850 100644 --- a/test/AgController/index.spec.ts +++ b/test/AgController/index.spec.ts @@ -95,6 +95,33 @@ describe('AgControler', () => { expect(agController.clients.length).toBe(0); await delay(1000); }); + it('invokes client.socket.listener with the socket as its receiver, not detached (production crash regression)', async () => { + // Regression for the prod crash-loop: handleDisconnect used to pull + // `listener` off `client`/`client.socket` into a bare variable and call + // it standalone, so it ran with no receiver. async-stream-emitter reads + // `this._listenerDemux` internally, so a detached call throws + // "Cannot read properties of undefined (reading 'stream')" on every + // incoming socket connection. This asserts `listener` is called AS A + // METHOD of its owning object (`this` bound to the socket), matching + // how SocketCluster's real ConnectionData shape only exposes + // `client.socket.listener`, never `client.listener` directly. + const agController = new AgController(aStub); + const socketOwner = { + receiverThis: undefined as unknown, + listener() { + socketOwner.receiverThis = this; + return { createConsumer: () => ({ next: () => Promise.resolve({ done: true }) }) }; + }, + }; + const sStub: IClient = { + id: '123', + socket: socketOwner, + }; + const to = null as unknown as NodeJS.Timeout; + agController.handleDisconnect(sStub, to); + await delay(500); + expect(socketOwner.receiverThis).toBe(socketOwner); + }); it('sends a pulse', () => { const agController = new AgController(aStub); agController.clients = ['123']; From 3ac71f9a65fa5c1c1dee5fee4becd32a5f9bf6e1 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Sat, 1 Aug 2026 16:48:11 -0400 Subject: [PATCH 4/6] Gate the build on a real production-mode socket connection smoke test Add scripts/smoke-prod-socket.mjs and wire it into CircleCI. Unlike the AgController unit tests, this boots the ACTUAL compiled server (NODE_ENV=production, matching Heroku -- agServerUtils.routing only skips resetData() in production, so dev/test-mode boot takes a different path than the one that crashed), connects a real socketcluster-client, holds the connection briefly, disconnects cleanly, and asserts the process is still alive and the HTTP server still answers. It's black-box: it doesn't know which bug crashes the boot path, so it also guards future regressions here, not just this one. Verified locally against a throwaway local MongoDB (via docker, discarded after use -- no WebJamApps database touched): - unfixed code: FAILS, reproducing the exact production stack trace (async-stream-emitter TypeError: Cannot read properties of undefined (reading 'stream') at AgController.handleDisconnect -> sendPulse -> addSocket -> handleConnections) - fixed code: PASSES Reuses MONGO_DB_URI or TEST_DB (whichever CircleCI already provides for the unit suite) rather than adding a new secret; fails fast with a clear message if neither is set instead of hanging. socketcluster-client is already a runtime "dependencies" entry (not added here), so it survives the existing `npm install --omit=dev` prod-parity step. --- .circleci/config.yml | 3 + scripts/smoke-prod-socket.mjs | 146 ++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 scripts/smoke-prod-socket.mjs diff --git a/.circleci/config.yml b/.circleci/config.yml index 53c1be5..b9ec36d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,9 @@ jobs: rm -rf node_modules build npm install --omit=dev node scripts/smoke-start.mjs + - run: + name: Production socket connection smoke test (regression: detached-listener crash-loop outage) + command: 'node scripts/smoke-prod-socket.mjs' workflows: build-and-test: diff --git a/scripts/smoke-prod-socket.mjs b/scripts/smoke-prod-socket.mjs new file mode 100644 index 0000000..03220cd --- /dev/null +++ b/scripts/smoke-prod-socket.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// Black-box liveness smoke test for the REAL production boot path (Heroku parity). +// +// Why this exists (2026-08-01, joshandmariamusic.com / web-jam.com outage): +// the AgController unit tests drive `handleDisconnect` with a fake `listener` +// that is a plain function — a plain function doesn't care about its +// receiver, so a *detached* `client.listener(...)` call passes fine under +// test even though the REAL `async-stream-emitter` implementation reads +// `this._listenerDemux` and throws when called with no receiver. Coverage +// stayed green while production crash-looped on every incoming socket +// connection. Nothing in CI ever booted the real build and opened a real +// connection through it. +// +// This script does exactly that: it boots the ACTUAL compiled server with +// NODE_ENV=production (this matters — agServerUtils.routing only skips +// resetData() when NODE_ENV === 'production', so dev/test-mode boot takes a +// DIFFERENT path than the one that crashed in prod), connects a REAL +// socketcluster-client, holds the connection briefly, disconnects cleanly, +// and asserts the server process is still alive and /health-check still +// answers. It is intentionally black-box — it doesn't know or care which +// bug crashes the boot path, so it also guards against future regressions +// in this area, not just this specific one. +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { create } from 'socketcluster-client'; + +const BOOT_TIMEOUT_MS = 20000; +const HOLD_MS = 1500; +const SETTLE_MS = 750; + +const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +const getFreePort = () => new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); +}); + +const mongoUri = process.env.MONGO_DB_URI || process.env.TEST_DB; +if (!mongoUri) { + console.error('smoke-prod-socket FAILED: no MONGO_DB_URI or TEST_DB set in the environment.'); + console.error('This test boots the server with NODE_ENV=production, which requires a real, reachable Mongo connection string (reusing the same test-db CircleCI already provides for the unit suite is fine — it never needs real prod data).'); + process.exit(1); +} + +let stderr = ''; +let stdout = ''; +let childExited = false; +let exitInfo = null; +let finished = false; +let childRef = null; + +const cleanup = (code) => { + if (finished) return; + finished = true; + if (childRef && !childExited) { + try { childRef.kill('SIGKILL'); } catch { /* already gone */ } + } + process.exitCode = code; +}; + +const fail = (msg) => { + console.error(`smoke-prod-socket FAILED: ${msg}`); + if (stderr.trim()) console.error(`--- child stderr ---\n${stderr}`); + if (stdout.trim()) console.error(`--- child stdout ---\n${stdout}`); + cleanup(1); +}; + +const run = async () => { + const port = await getFreePort(); + const child = spawn(process.execPath, ['build/src/index.js'], { + env: { + ...process.env, + NODE_ENV: 'production', + PORT: String(port), + SOCKETCLUSTER_PORT: String(port), + MONGO_DB_URI: mongoUri, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + childRef = child; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + + const exitPromise = new Promise((resolve) => { + child.on('exit', (code, signal) => { + childExited = true; + exitInfo = { code, signal }; + resolve({ type: 'exit' }); + }); + }); + + const client = create({ + hostname: 'localhost', port, secure: false, connectTimeout: BOOT_TIMEOUT_MS, + }); + const connectPromise = client.listener('connect').once().then(() => ({ type: 'connect' })); + const timeoutPromise = delay(BOOT_TIMEOUT_MS).then(() => ({ type: 'timeout' })); + + const first = await Promise.race([exitPromise, connectPromise, timeoutPromise]); + if (first.type === 'exit') { + fail(`server process exited before a client could connect (code=${exitInfo?.code}, signal=${exitInfo?.signal})`); + return; + } + if (first.type === 'timeout') { + fail(`no socketcluster-client connection succeeded within ${BOOT_TIMEOUT_MS}ms`); + client.disconnect(); + return; + } + + // Connected. Hold the connection briefly, then disconnect cleanly — this + // is exactly the connect/disconnect lifecycle that used to crash-loop the + // server (the crash actually fires on connect, inside addSocket -> + // sendPulse -> handleDisconnect, but we exercise the full real lifecycle + // rather than assume that detail). + await delay(HOLD_MS); + client.disconnect(); + await delay(SETTLE_MS); + + if (childExited) { + fail(`server process exited after a real client connected + disconnected (code=${exitInfo?.code}, signal=${exitInfo?.signal}) — this is the detached-listener regression`); + return; + } + + // Any completed HTTP response (regardless of status code) is evidence the + // HTTP server is still alive and serving — this app registers a catch-all + // route ahead of /health-check, so status code isn't a meaningful signal + // here; only "did the server answer at all" is. + try { + const res = await fetch(`http://localhost:${port}/`); + stdout += `\n[smoke] GET / after round-trip -> HTTP ${res.status}\n`; + } catch (e) { + fail(`HTTP server did not answer a request after the socket round-trip: ${e instanceof Error ? e.message : String(e)}`); + return; + } + + console.log('smoke-prod-socket OK: production-mode boot survived a real socketcluster-client connect + disconnect, and the HTTP server still answers.'); + cleanup(0); +}; + +run().catch((e) => { + console.error(`smoke-prod-socket FAILED: unexpected error: ${e instanceof Error ? e.stack : String(e)}`); + cleanup(1); +}); From d3066e493685a58db5aca0ce6c228535539e9d15 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Sat, 1 Aug 2026 16:51:45 -0400 Subject: [PATCH 5/6] Fix CircleCI YAML parse failure: quote the name value The step name's unquoted colon-space ("...(regression: detached-listener...") was read by YAML as a nested key/value separator, so the whole config failed to parse and the pipeline never started -- meaning the smoke gate added in the previous commit had never actually run in CI. Quoting the scalar fixes the parse; command/name padding already matched the surrounding steps. Validated locally before pushing: - python3 -c "import yaml,sys; yaml.safe_load(open('.circleci/config.yml'))" -> parses cleanly - circleci config validate .circleci/config.yml -> "Config file at .circleci/config.yml is valid." --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b9ec36d..43c1695 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ jobs: npm install --omit=dev node scripts/smoke-start.mjs - run: - name: Production socket connection smoke test (regression: detached-listener crash-loop outage) + name: 'Production socket connection smoke test (regression: detached-listener crash-loop outage)' command: 'node scripts/smoke-prod-socket.mjs' workflows: From c7ff71375d103ea132ec51b1ffa32cd9ccab2d65 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Sat, 1 Aug 2026 17:10:09 -0400 Subject: [PATCH 6/6] Fix smoke-prod-socket harness: dial a probed-reachable loopback, not 'localhost' (v3.0.15) CircleCI build 504 on dev failed: the server logged [Active] (listening) and never crashed -- no async-stream-emitter TypeError, and the script's own exit-vs-timeout distinction correctly reported "no connection succeeded" rather than "server process exited". The AgController fix from #262 is not implicated; this was a defect in the test harness itself. Root cause, verified empirically (not just restating the hypothesis): ran the actual CI base image (cimg/node:24.18-browsers) and confirmed Node 24's *default* dns.lookup('localhost') resolves ::1 (IPv6) first in that image. src/index.ts calls httpServer.listen(SOCKETCLUSTER_PORT) with no host, so which family actually ends up reachable depends on the environment. Dialling by the hostname 'localhost' left address selection to Node's resolver/DNS ordering, which is exactly the kind of thing that differs between a local Docker Desktop sandbox (where both loopback families happened to work in my repro) and CircleCI's actual container networking (where the real job observed zero successful connections in 20s despite a healthy listener -- the classic signature of a family that's present but not actually routable, which a Docker Desktop bridge network does not reproduce). Fix: probe both 127.0.0.1 and ::1 with a short, bounded raw-TCP connect BEFORE ever handing a hostname to socketcluster-client, and dial whichever one actually answers. This sidesteps DNS/dual-stack ambiguity entirely and is correct regardless of which family (if any) is broken in a given environment -- no more guessing. Also: capture the client's 'error' listener continuously so any future handshake-level failure carries a concrete reason in the final message instead of a bare "didn't connect", and log the raw dns.lookup ordering up front for visibility in CI logs. Timeout budget is unchanged (20s total), not raised -- the fix is WHICH address is dialed and WHY a failure happened, not how long we wait. Verified locally against cimg/node:24.18-browsers with a throwaway docker MongoDB (both discarded after use): - fixed AgController + fixed harness -> smoke-prod-socket OK - reverted AgController (temporarily, not committed) + fixed harness -> smoke-prod-socket FAILS with the exact production stack trace (TypeError: Cannot read properties of undefined (reading 'stream') at AsyncStreamEmitter.listener -> AgController.handleDisconnect -> sendPulse -> addSocket -> handleConnections), proving the gate still genuinely catches the regression. --- package-lock.json | 4 +- package.json | 2 +- scripts/smoke-prod-socket.mjs | 127 +++++++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index c057950..140794b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webjamsocketserver", - "version": "3.0.14", + "version": "3.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webjamsocketserver", - "version": "3.0.14", + "version": "3.0.15", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a7845c0..c4e064b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webjamsocketserver", "description": "Uses latest version of socketcluster-server", - "version": "3.0.14", + "version": "3.0.15", "license": "MIT", "type": "module", "main": "build/src/index.js", diff --git a/scripts/smoke-prod-socket.mjs b/scripts/smoke-prod-socket.mjs index 03220cd..ad7d661 100644 --- a/scripts/smoke-prod-socket.mjs +++ b/scripts/smoke-prod-socket.mjs @@ -16,17 +16,38 @@ // resetData() when NODE_ENV === 'production', so dev/test-mode boot takes a // DIFFERENT path than the one that crashed in prod), connects a REAL // socketcluster-client, holds the connection briefly, disconnects cleanly, -// and asserts the server process is still alive and /health-check still +// and asserts the server process is still alive and the HTTP server still // answers. It is intentionally black-box — it doesn't know or care which // bug crashes the boot path, so it also guards against future regressions // in this area, not just this specific one. +// +// Loopback host note (2026-08-01, CircleCI build 504): `src/index.ts` calls +// `httpServer.listen(SOCKETCLUSTER_PORT)` with no host, so the OS picks the +// bind address. Dialling by the hostname 'localhost' leaves address +// selection to Node's resolver, and that has bitten this exact test once +// already: confirmed empirically against the actual CI image +// (cimg/node:24.18-browsers) that Node 24's *default* `dns.lookup` +// ('localhost') resolves `::1` first. If a given environment's IPv6 +// loopback is present but not actually routable (a known class of container +// networking quirk we could not force-reproduce locally, but which matches +// the observed CI symptom: server logged [Active], client never connected +// in 20s, no crash signature), a hostname-based dial can hang or fail while +// the server is perfectly healthy on the other family. Rather than guess +// which family is broken in a given environment, this script empirically +// probes both loopback addresses with a short, bounded raw-TCP connect and +// dials whichever one actually answers -- self-diagnosing, and correct +// regardless of which family (if any) is broken. import { spawn } from 'node:child_process'; import net from 'node:net'; +import dns from 'node:dns'; import { create } from 'socketcluster-client'; const BOOT_TIMEOUT_MS = 20000; +const PROBE_TIMEOUT_MS = 2000; +const CLIENT_CONNECT_TIMEOUT_MS = 8000; const HOLD_MS = 1500; const SETTLE_MS = 750; +const LOOPBACK_CANDIDATES = ['127.0.0.1', '::1']; const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); @@ -39,6 +60,32 @@ const getFreePort = () => new Promise((resolve, reject) => { }); }); +// Raw TCP reachability probe -- deliberately independent of DNS/hostname +// resolution and of socketcluster-client, so it tells us the truth about +// which loopback family actually has something listening/reachable on it. +const probeConnect = (host, port, timeoutMs) => new Promise((resolve) => { + let settled = false; + const sock = net.connect({ host, port }); + const finish = (ok, reason) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { sock.destroy(); } catch { /* already gone */ } + resolve({ host, ok, reason }); + }; + const timer = setTimeout(() => finish(false, 'timeout'), timeoutMs); + sock.on('connect', () => finish(true, null)); + sock.on('error', (e) => finish(false, e.code || e.message)); +}); + +const logDnsOrdering = () => new Promise((resolve) => { + dns.lookup('localhost', { all: true }, (err, results) => { + if (err) console.log(`[smoke] dns.lookup('localhost', {all:true}) errored: ${err.message}`); + else console.log(`[smoke] dns.lookup('localhost', {all:true}) -> ${JSON.stringify(results)}`); + resolve(); + }); +}); + const mongoUri = process.env.MONGO_DB_URI || process.env.TEST_DB; if (!mongoUri) { console.error('smoke-prod-socket FAILED: no MONGO_DB_URI or TEST_DB set in the environment.'); @@ -70,6 +117,8 @@ const fail = (msg) => { }; const run = async () => { + await logDnsOrdering(); + const port = await getFreePort(); const child = spawn(process.execPath, ['build/src/index.js'], { env: { @@ -89,23 +138,83 @@ const run = async () => { child.on('exit', (code, signal) => { childExited = true; exitInfo = { code, signal }; - resolve({ type: 'exit' }); + resolve(); }); }); + const bootDeadline = Date.now() + BOOT_TIMEOUT_MS; + + // Find which loopback family is actually reachable before ever handing a + // hostname to socketcluster-client. Keep retrying (the child needs a + // moment to bind) until one candidate answers, the child exits, or we run + // out of boot budget. + let reachableHost = null; + const lastProbeResults = new Map(); + while (!reachableHost && !childExited && Date.now() < bootDeadline) { + // eslint-disable-next-line no-await-in-loop + const probes = await Promise.all( + LOOPBACK_CANDIDATES.map((host) => probeConnect(host, port, PROBE_TIMEOUT_MS)), + ); + for (const p of probes) lastProbeResults.set(p.host, p); + const winner = probes.find((p) => p.ok); + if (winner) { + reachableHost = winner.host; + break; + } + if (!childExited) { + // eslint-disable-next-line no-await-in-loop + await delay(250); + } + } + + const probeSummary = () => LOOPBACK_CANDIDATES + .map((h) => { + const r = lastProbeResults.get(h); + return `${h} -> ${r ? (r.ok ? 'OK' : `FAILED (${r.reason})`) : 'not probed'}`; + }) + .join(', '); + + if (childExited) { + fail(`server process exited before any loopback address became reachable (code=${exitInfo?.code}, signal=${exitInfo?.signal}). Probe results: ${probeSummary()}`); + return; + } + if (!reachableHost) { + fail(`no loopback address (${LOOPBACK_CANDIDATES.join(', ')}) accepted a raw TCP connection on port ${port} within ${BOOT_TIMEOUT_MS}ms, even though the server process is still running. Probe results: ${probeSummary()}`); + return; + } + console.log(`[smoke] reachable loopback address: ${reachableHost} (${probeSummary()})`); + + // TCP-level reachability is confirmed; now do the real socketcluster + // handshake against the address we KNOW is listening. Capture 'error' + // continuously so a handshake-level failure carries a concrete reason + // instead of a bare "didn't connect". const client = create({ - hostname: 'localhost', port, secure: false, connectTimeout: BOOT_TIMEOUT_MS, + hostname: reachableHost, port, secure: false, connectTimeout: CLIENT_CONNECT_TIMEOUT_MS, }); + let lastClientError = null; + (async () => { + const consumer = client.listener('error').createConsumer(); + while (true) { + // eslint-disable-next-line no-await-in-loop + const { value, done } = await consumer.next(); + if (value?.error) lastClientError = value.error; + if (done) break; + } + })(); + + const remainingMs = Math.max(1000, bootDeadline - Date.now()); const connectPromise = client.listener('connect').once().then(() => ({ type: 'connect' })); - const timeoutPromise = delay(BOOT_TIMEOUT_MS).then(() => ({ type: 'timeout' })); + const timeoutPromise = delay(remainingMs).then(() => ({ type: 'timeout' })); + const exitRace = exitPromise.then(() => ({ type: 'exit' })); - const first = await Promise.race([exitPromise, connectPromise, timeoutPromise]); + const first = await Promise.race([exitRace, connectPromise, timeoutPromise]); if (first.type === 'exit') { - fail(`server process exited before a client could connect (code=${exitInfo?.code}, signal=${exitInfo?.signal})`); + fail(`server process exited before the socketcluster-client handshake completed against ${reachableHost}:${port} (code=${exitInfo?.code}, signal=${exitInfo?.signal})`); return; } if (first.type === 'timeout') { - fail(`no socketcluster-client connection succeeded within ${BOOT_TIMEOUT_MS}ms`); + const reasonMsg = lastClientError ? ` Last client error: ${lastClientError.message || lastClientError}` : ' No error event was emitted by the client.'; + fail(`raw TCP reached ${reachableHost}:${port} but the socketcluster handshake did not complete within ${remainingMs}ms.${reasonMsg}`); client.disconnect(); return; } @@ -129,14 +238,14 @@ const run = async () => { // route ahead of /health-check, so status code isn't a meaningful signal // here; only "did the server answer at all" is. try { - const res = await fetch(`http://localhost:${port}/`); + const res = await fetch(`http://${reachableHost}:${port}/`); stdout += `\n[smoke] GET / after round-trip -> HTTP ${res.status}\n`; } catch (e) { fail(`HTTP server did not answer a request after the socket round-trip: ${e instanceof Error ? e.message : String(e)}`); return; } - console.log('smoke-prod-socket OK: production-mode boot survived a real socketcluster-client connect + disconnect, and the HTTP server still answers.'); + console.log(`smoke-prod-socket OK: production-mode boot survived a real socketcluster-client connect + disconnect over ${reachableHost}, and the HTTP server still answers.`); cleanup(0); };