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:
5
.changeset/auto-port-fallback.md
Normal file
5
.changeset/auto-port-fallback.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Auto-assign random port when dashboard port is already in use instead of crashing with EADDRINUSE.
|
||||||
@@ -32,7 +32,26 @@ vi.mock("@kb/core", () => ({
|
|||||||
|
|
||||||
// ── Mock @kb/dashboard ─────────────────────────────────────────────
|
// ── 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", () => ({
|
vi.mock("@kb/dashboard", () => ({
|
||||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,7 +34,26 @@ vi.mock("@kb/core", () => ({
|
|||||||
|
|
||||||
// ── Mock @kb/dashboard ─────────────────────────────────────────────
|
// ── 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", () => ({
|
vi.mock("@kb/dashboard", () => ({
|
||||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||||
}));
|
}));
|
||||||
@@ -217,3 +236,118 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
|||||||
expect(mergedIds).not.toContain("KB-PAUSED");
|
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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
import { TaskStore } from "@kb/core";
|
import { TaskStore } from "@kb/core";
|
||||||
import { createServer } from "@kb/dashboard";
|
import { createServer } from "@kb/dashboard";
|
||||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@kb/engine";
|
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();
|
||||||
console.log(` kb board`);
|
console.log(` kb board`);
|
||||||
console.log(` ────────────────────────`);
|
console.log(` ────────────────────────`);
|
||||||
console.log(` → http://localhost:${port}`);
|
console.log(` → http://localhost:${actualPort}`);
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` Tasks stored in .kb/tasks/`);
|
console.log(` Tasks stored in .kb/tasks/`);
|
||||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||||
@@ -242,7 +260,7 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
if (opts.open !== false) {
|
if (opts.open !== false) {
|
||||||
openBrowser(`http://localhost:${port}`);
|
openBrowser(`http://localhost:${actualPort}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user