diff --git a/src/webui/daemon-server.ts b/src/webui/daemon-server.ts index 1fca51f..56e3511 100644 --- a/src/webui/daemon-server.ts +++ b/src/webui/daemon-server.ts @@ -46,7 +46,23 @@ export async function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOpt // Start pkc-js RPC const log = PKCLogger("bitsocial-cli:daemon:startDaemonServer"); const webuiExpressApp = express(); - const httpServer = webuiExpressApp.listen(Number(rpcUrl.port)); + // Wait for bind to actually complete before returning. Calling express.listen() without + // awaiting 'listening' lets startup proceed before the port is accepting connections, + // and without an 'error' handler a bind failure becomes an uncaughtException that kills + // the daemon *after* it has already logged "Communities in data path" — see issue #42. + const httpServer = await new Promise((resolve, reject) => { + const server = webuiExpressApp.listen(Number(rpcUrl.port)); + const onListening = () => { + server.off("error", onError); + resolve(server); + }; + const onError = (err: Error) => { + server.off("listening", onListening); + reject(err); + }; + server.once("listening", onListening); + server.once("error", onError); + }); log("HTTP server is running on", "0.0.0.0" + ":" + rpcUrl.port); const rpcAuthKey = await _generateRpcAuthKeyIfNotExisting(pkcOptions.dataPath!); const PKCRpc = await import("@pkcprotocol/pkc-js/rpc"); diff --git a/test/webui/daemon-server.test.ts b/test/webui/daemon-server.test.ts new file mode 100644 index 0000000..3fa6eba --- /dev/null +++ b/test/webui/daemon-server.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import net from "net"; +import { directory as randomDirectory } from "tempy"; +import { startDaemonServer } from "../../dist/webui/daemon-server.js"; + +// Regression test for issue #42: +// startDaemonServer used to call webuiExpressApp.listen(port) fire-and-forget — no +// await on 'listening', no 'error' handler. If bind failed, the promise still resolved +// and the bind error showed up later as an uncaughtException, crashing the daemon +// AFTER the test helper had already accepted "Communities in data path" as readiness. +// The contract we want: if the port can't be bound, startDaemonServer must reject. +describe("startDaemonServer bind contract", () => { + it("rejects when the RPC port is already taken (regression for issue #42)", async () => { + // The blocker must bind the *same way* the daemon does — no host argument — + // so it lands on whatever default Node picks for this platform. On macOS and + // Windows that default is the IPv6 wildcard `::` with IPV6_V6ONLY=true, which + // does NOT conflict with a 0.0.0.0 bind. Binding the blocker to 127.0.0.1 or + // 0.0.0.0 would let the daemon's listen succeed there and never hit EADDRINUSE. + const blocker = net.createServer(); + const port = await new Promise((resolve, reject) => { + blocker.once("listening", () => { + const addr = blocker.address(); + if (addr && typeof addr === "object") resolve(addr.port); + else reject(new Error(`unexpected address ${JSON.stringify(addr)}`)); + }); + blocker.once("error", reject); + blocker.listen(0); + }); + + // Swallow any stray uncaughtException emitted by an unguarded server.listen() + // so the test process survives long enough to assert the actual behavior. + const stray: Error[] = []; + const uncaughtHandler = (err: Error) => stray.push(err); + process.on("uncaughtException", uncaughtHandler); + + try { + await expect( + startDaemonServer( + new URL(`ws://127.0.0.1:${port}`), + new URL("http://127.0.0.1:6754"), + { dataPath: randomDirectory() } + ) + ).rejects.toThrow(/EADDRINUSE|address already in use/i); + // And no stray uncaughtException either — the bind error must come back + // through the promise, not the process. + expect(stray).toEqual([]); + } finally { + process.off("uncaughtException", uncaughtHandler); + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); +});