Skip to content

Commit 6a2f9da

Browse files
committed
fix(daemon-server): await express listen() so port is bound before startup completes
`webuiExpressApp.listen(port)` was fire-and-forget — no await on 'listening', no 'error' handler. Under load `startDaemonServer` could return (and the daemon could log "Communities in data path") before the OS had finished binding the RPC port, and a bind failure showed up later as an uncaughtException that crashed the daemon *after* it had already advertised itself as ready. The webui suite's first fetch sometimes hit the open window and failed with ECONNREFUSED. Wrap listen() in a promise that resolves on 'listening' and rejects on 'error'. Adds a unit test that pre-binds the port and asserts the function rejects with EADDRINUSE. Fixes issue #42.
1 parent 2a08e93 commit 6a2f9da

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/webui/daemon-server.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,23 @@ export async function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOpt
4646
// Start pkc-js RPC
4747
const log = PKCLogger("bitsocial-cli:daemon:startDaemonServer");
4848
const webuiExpressApp = express();
49-
const httpServer = webuiExpressApp.listen(Number(rpcUrl.port));
49+
// Wait for bind to actually complete before returning. Calling express.listen() without
50+
// awaiting 'listening' lets startup proceed before the port is accepting connections,
51+
// and without an 'error' handler a bind failure becomes an uncaughtException that kills
52+
// the daemon *after* it has already logged "Communities in data path" — see issue #42.
53+
const httpServer = await new Promise<import("http").Server>((resolve, reject) => {
54+
const server = webuiExpressApp.listen(Number(rpcUrl.port));
55+
const onListening = () => {
56+
server.off("error", onError);
57+
resolve(server);
58+
};
59+
const onError = (err: Error) => {
60+
server.off("listening", onListening);
61+
reject(err);
62+
};
63+
server.once("listening", onListening);
64+
server.once("error", onError);
65+
});
5066
log("HTTP server is running on", "0.0.0.0" + ":" + rpcUrl.port);
5167
const rpcAuthKey = await _generateRpcAuthKeyIfNotExisting(pkcOptions.dataPath!);
5268
const PKCRpc = await import("@pkcprotocol/pkc-js/rpc");

test/webui/daemon-server.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from "vitest";
2+
import net from "net";
3+
import { directory as randomDirectory } from "tempy";
4+
import { startDaemonServer } from "../../dist/webui/daemon-server.js";
5+
6+
// Regression test for issue #42:
7+
// startDaemonServer used to call webuiExpressApp.listen(port) fire-and-forget — no
8+
// await on 'listening', no 'error' handler. If bind failed, the promise still resolved
9+
// and the bind error showed up later as an uncaughtException, crashing the daemon
10+
// AFTER the test helper had already accepted "Communities in data path" as readiness.
11+
// The contract we want: if the port can't be bound, startDaemonServer must reject.
12+
describe("startDaemonServer bind contract", () => {
13+
it("rejects when the RPC port is already taken (regression for issue #42)", async () => {
14+
const blocker = net.createServer();
15+
const port = await new Promise<number>((resolve, reject) => {
16+
blocker.once("listening", () => {
17+
const addr = blocker.address();
18+
if (addr && typeof addr === "object") resolve(addr.port);
19+
else reject(new Error(`unexpected address ${JSON.stringify(addr)}`));
20+
});
21+
blocker.once("error", reject);
22+
blocker.listen(0, "127.0.0.1");
23+
});
24+
25+
// Swallow any stray uncaughtException emitted by an unguarded server.listen()
26+
// so the test process survives long enough to assert the actual behavior.
27+
const stray: Error[] = [];
28+
const uncaughtHandler = (err: Error) => stray.push(err);
29+
process.on("uncaughtException", uncaughtHandler);
30+
31+
try {
32+
await expect(
33+
startDaemonServer(
34+
new URL(`ws://127.0.0.1:${port}`),
35+
new URL("http://127.0.0.1:6754"),
36+
{ dataPath: randomDirectory() }
37+
)
38+
).rejects.toThrow(/EADDRINUSE|address already in use/i);
39+
// And no stray uncaughtException either — the bind error must come back
40+
// through the promise, not the process.
41+
expect(stray).toEqual([]);
42+
} finally {
43+
process.off("uncaughtException", uncaughtHandler);
44+
await new Promise<void>((resolve) => blocker.close(() => resolve()));
45+
}
46+
});
47+
});

0 commit comments

Comments
 (0)