fix: keep config dashboard alive when interface detection fails

This commit is contained in:
Tam Nhu Tran committed 2026-04-28 11:39:40 -04:00
1 parent 0867378644
commit 119aa3be98
3 files changed
+58 -6

No files matched your search

+16 -3
View File
@@ -45,7 +45,7 @@ export function normalizeDashboardHost(host: string | undefined): string | undef
export function resolveDashboardUrls( export function resolveDashboardUrls(
host: string | undefined, host: string | undefined,
port: number, port: number,
networkInterfaces: NetworkInterfacesMap = os.networkInterfaces() networkInterfaces?: NetworkInterfacesMap
): DashboardUrls { ): DashboardUrls {
const bindHost = normalizeDashboardHost(host); const bindHost = normalizeDashboardHost(host);
if (!bindHost) { if (!bindHost) {
@@ -58,7 +58,7 @@ export function resolveDashboardUrls(
return { return {
bindHost, bindHost,
browserUrl: `http://localhost:${port}`, browserUrl: `http://localhost:${port}`,
networkUrls: getExternalIpv4Urls(port, networkInterfaces), networkUrls: getExternalIpv4Urls(port, networkInterfaces ?? getNetworkInterfacesSafe()),
}; };
} }
@@ -70,8 +70,12 @@ export function resolveDashboardUrls(
function getExternalIpv4Urls( function getExternalIpv4Urls(
port: number, port: number,
networkInterfaces: NetworkInterfacesMap networkInterfaces: NetworkInterfacesMap | undefined
): string[] | undefined { ): string[] | undefined {
if (!networkInterfaces) {
return undefined;
}
const urls: string[] = []; const urls: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -97,6 +101,15 @@ function getExternalIpv4Urls(
return urls.length > 0 ? urls : undefined; return urls.length > 0 ? urls : undefined;
} }
function getNetworkInterfacesSafe(): NetworkInterfacesMap | undefined {
try {
// Some constrained environments (for example proot) throw here; keep the local dashboard URL.
return os.networkInterfaces();
} catch {
return undefined;
}
}
function formatHostForUrl(host: string): string { function formatHostForUrl(host: string): string {
if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) { if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) {
return `[${host}]`; return `[${host}]`;
+24 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as os from 'os';
import { handleConfigCommand } from '../../../src/commands/config-command'; import { handleConfigCommand } from '../../../src/commands/config-command';
import { resolveNamedCommand } from '../../../src/commands/named-command-router'; import { resolveNamedCommand } from '../../../src/commands/named-command-router';
@@ -104,7 +105,9 @@ describe('config command dashboard startup', () => {
throw new Error(`process.exit(${code ?? 0})`); throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit; }) as typeof process.exit;
await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow('process.exit(0)'); await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow(
'process.exit(0)'
);
expect(startServerCalls).toHaveLength(0); expect(startServerCalls).toHaveLength(0);
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
@@ -191,6 +194,25 @@ describe('config command dashboard startup', () => {
expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited'); expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited');
}); });
it('still opens the dashboard when wildcard network detection is unavailable', async () => {
const networkInterfacesSpy = spyOn(os, 'networkInterfaces').mockImplementation(() => {
throw new Error('uv_interface_addresses returned Unknown system error 13');
});
try {
await handleConfigCommand([], createTestDeps());
expect(startServerCalls).toHaveLength(1);
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(errorLines).toHaveLength(0);
} finally {
networkInterfacesSpy.mockRestore();
}
});
it('fails cleanly when the server cannot bind the requested host', async () => { it('fails cleanly when the server cannot bind the requested host', async () => {
startServerError = new Error( startServerError = new Error(
'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it, spyOn } from 'bun:test';
import * as os from 'os';
import { import {
isLoopbackHost, isLoopbackHost,
@@ -66,4 +67,20 @@ describe('config dashboard host helpers', () => {
expect(urls.bindHost).toBe('::1'); expect(urls.bindHost).toBe('::1');
expect(urls.browserUrl).toBe('http://[::1]:3000'); expect(urls.browserUrl).toBe('http://[::1]:3000');
}); });
it('falls back to localhost-only URLs when interface enumeration fails', () => {
const networkInterfacesSpy = spyOn(os, 'networkInterfaces').mockImplementation(() => {
throw new Error('uv_interface_addresses returned Unknown system error 13');
});
try {
const urls = resolveDashboardUrls('::', 3000);
expect(urls.bindHost).toBe('::');
expect(urls.browserUrl).toBe('http://localhost:3000');
expect(urls.networkUrls).toBeUndefined();
} finally {
networkInterfacesSpy.mockRestore();
}
});
}); });