feat(KB-137): add automatic port fallback when dashboard port is in use

- Handle EADDRINUSE by retrying with port 0 (OS-assigned random port)
- Print warning message showing original and fallback port
- Update banner and browser-open to use the actual bound port
- Add tests for successful listen, fallback, and warning behavior
- Add changeset for patch release
This commit is contained in:
Dustin Byrne
2026-03-27 23:51:21 -04:00
parent 95cc34bf8b
commit d19b51f7fa
4 changed files with 181 additions and 5 deletions

View File

@@ -32,7 +32,26 @@ vi.mock("@kb/core", () => ({
// ── Mock @kb/dashboard ─────────────────────────────────────────────
const mockListen = vi.fn();
/** Create a mock server (EventEmitter) that simulates net.Server behavior. */
function createMockServer(portToReturn: number = 0) {
const emitter = new EventEmitter();
const server = Object.assign(emitter, {
listen: vi.fn((_port?: number) => {
process.nextTick(() => emitter.emit("listening"));
return server;
}),
address: vi.fn(() => ({ port: portToReturn, family: "IPv4", address: "127.0.0.1" })),
close: vi.fn(),
});
return server;
}
const mockListen = vi.fn((port: number) => {
const server = createMockServer(port);
process.nextTick(() => server.emit("listening"));
return server;
});
vi.mock("@kb/dashboard", () => ({
createServer: vi.fn(() => ({ listen: mockListen })),
}));

View File

@@ -34,7 +34,26 @@ vi.mock("@kb/core", () => ({
// ── Mock @kb/dashboard ─────────────────────────────────────────────
const mockListen = vi.fn();
/** Create a mock server (EventEmitter) that simulates net.Server behavior. */
function createMockServer(portToReturn: number = 0) {
const emitter = new EventEmitter();
const server = Object.assign(emitter, {
listen: vi.fn((_port?: number) => {
process.nextTick(() => emitter.emit("listening"));
return server;
}),
address: vi.fn(() => ({ port: portToReturn, family: "IPv4", address: "127.0.0.1" })),
close: vi.fn(),
});
return server;
}
const mockListen = vi.fn((port: number) => {
const server = createMockServer(port);
process.nextTick(() => server.emit("listening"));
return server;
});
vi.mock("@kb/dashboard", () => ({
createServer: vi.fn(() => ({ listen: mockListen })),
}));
@@ -217,3 +236,118 @@ describe("runDashboard — auto-merge pause exclusion", () => {
expect(mergedIds).not.toContain("KB-PAUSED");
});
});
describe("runDashboard — port fallback on EADDRINUSE", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
const engine = await import("@kb/engine");
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
);
consoleSpy = vi.spyOn(console, "log");
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("listens on the requested port when available", async () => {
await runDashboard(4040, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// mockListen should have been called with the requested port
expect(mockListen).toHaveBeenCalledWith(4040);
// Banner should show the requested port
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
);
// No warning should be printed
const warningCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("Port 4040 in use"),
);
expect(warningCalls).toHaveLength(0);
});
it("falls back to a random port on EADDRINUSE", async () => {
const fallbackPort = 54321;
const serverEmitter = new EventEmitter();
// Mock the server's own listen method (used for the retry with port 0)
const mockServerListen = vi.fn((_port?: number) => {
process.nextTick(() => serverEmitter.emit("listening"));
return serverEmitter;
});
Object.assign(serverEmitter, {
listen: mockServerListen,
address: vi.fn(() => ({ port: fallbackPort, family: "IPv4", address: "127.0.0.1" })),
close: vi.fn(),
});
// Override mockListen for one call: simulate EADDRINUSE
mockListen.mockImplementationOnce((_port: number) => {
process.nextTick(() => {
const err = new Error("listen EADDRINUSE: address already in use") as NodeJS.ErrnoException;
err.code = "EADDRINUSE";
serverEmitter.emit("error", err);
});
return serverEmitter;
});
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
// Server should have retried with port 0
expect(mockServerListen).toHaveBeenCalledWith(0);
// Banner should show the fallback port, not the requested port
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(`http://localhost:${fallbackPort}`),
);
});
it("prints a warning when port fallback occurs", async () => {
const fallbackPort = 12345;
const serverEmitter = new EventEmitter();
const mockServerListen = vi.fn((_port?: number) => {
process.nextTick(() => serverEmitter.emit("listening"));
return serverEmitter;
});
Object.assign(serverEmitter, {
listen: mockServerListen,
address: vi.fn(() => ({ port: fallbackPort, family: "IPv4", address: "127.0.0.1" })),
close: vi.fn(),
});
mockListen.mockImplementationOnce((_port: number) => {
process.nextTick(() => {
const err = new Error("listen EADDRINUSE: address already in use") as NodeJS.ErrnoException;
err.code = "EADDRINUSE";
serverEmitter.emit("error", err);
});
return serverEmitter;
});
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
// Should print warning with both the requested and actual ports
expect(consoleSpy).toHaveBeenCalledWith(
`⚠ Port 4040 in use, using ${fallbackPort} instead`,
);
});
});

View File

@@ -1,4 +1,5 @@
import { exec } from "node:child_process";
import type { AddressInfo } from "node:net";
import { TaskStore } from "@kb/core";
import { createServer } from "@kb/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@kb/engine";
@@ -226,11 +227,28 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
});
}
app.listen(port, () => {
const server = app.listen(port);
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
server.listen(0);
} else {
console.error(`Failed to start server: ${err.message}`);
process.exit(1);
}
});
server.on("listening", () => {
const actualPort = (server.address() as AddressInfo).port;
if (actualPort !== port) {
console.log(`⚠ Port ${port} in use, using ${actualPort} instead`);
}
console.log();
console.log(` kb board`);
console.log(` ────────────────────────`);
console.log(` → http://localhost:${port}`);
console.log(` → http://localhost:${actualPort}`);
console.log();
console.log(` Tasks stored in .kb/tasks/`);
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
@@ -242,7 +260,7 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
console.log();
if (opts.open !== false) {
openBrowser(`http://localhost:${port}`);
openBrowser(`http://localhost:${actualPort}`);
}
});
}