Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 39 additions & 33 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
import { IpcMainEvent, MenuItemConstructorOptions } from 'electron/main';
import find_process from 'find-process';
import * as fsPromises from 'fs/promises';
import * as net from 'net';
import fs from 'node:fs';
import { userInfo } from 'node:os';
import { promisify } from 'node:util';
Expand All @@ -50,6 +49,11 @@ import {
getPluginBinDirectories,
PluginManager,
} from './plugin-management';
import {
checkPortAvailability,
createLocalhostErrorMessage,
createNoPortsAvailableMessage,
} from './portUtils';
import { addRunCmdConsent, removeRunCmdConsent, runScript, setupRunCmdHandlers } from './runCmd';
import windowSize from './windowSize';

Expand Down Expand Up @@ -133,6 +137,7 @@ const isHeadlessMode = args.headless === true;
let disableGPU = args['disable-gpu'] === true;
const defaultPort = args.port || 4466;
let actualPort = defaultPort; // Will be updated when backend starts
let actualHost = 'localhost'; // Will be updated to '127.0.0.1' if localhost doesn't work
const MAX_PORT_ATTEMPTS = Math.abs(Number(process.env.HEADLAMP_MAX_PORT_ATTEMPTS) || 100); // Maximum number of ports to try

const useExternalServer = process.env.EXTERNAL_SERVER || false;
Expand Down Expand Up @@ -660,35 +665,14 @@ async function getShellEnv(): Promise<NodeJS.ProcessEnv> {
}
}

/**
* Check if a port is available by attempting to create a server on it
*/
async function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();

server.once('error', () => {
resolve(false);
});

server.once('listening', () => {
server.close(() => resolve(true));
});

try {
server.listen({ port, host: 'localhost', exclusive: true });
} catch (err) {
server.emit('error', err as NodeJS.ErrnoException);
}
});
}

/**
* Find an available port starting from the default port
* Tries to find a free port, skipping all occupied ports (including Headlamp)
* @returns Available port number, or throws if no port found after MAX_PORT_ATTEMPTS
*/
async function findAvailablePort(startPort: number): Promise<number> {
let resolutionFailedOnce = false;

for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
const port = startPort + i;
// Skip ports already used by another Headlamp instance.
Expand All @@ -701,21 +685,42 @@ async function findAvailablePort(startPort: number): Promise<number> {
);
continue;
}
const available = await isPortAvailable(port);

if (available) {
const result = await checkPortAvailability(port);

if (result.available) {
actualHost = result.host;
// Only warn once when we first detect resolution failure and use fallback
if (result.resolutionFailed && !resolutionFailedOnce) {
resolutionFailedOnce = true;
console.warn(
`Note: 'localhost' resolution failed (${result.errorCode}), using '${result.host}' as fallback for all subsequent checks.`
);
}
if (port !== startPort) {
console.info(`Port ${startPort} is in use, using port ${port} instead`);
}
return port;
}

// Track if we've seen resolution failures (not just port occupied)
if (result.resolutionFailed && !resolutionFailedOnce) {
resolutionFailedOnce = true;
}

console.info(`Port ${port} is occupied by another process, trying next port...`);
}

throw new Error(
`Could not find an available port after ${MAX_PORT_ATTEMPTS} attempts starting from ${startPort}`
);
// If we exhausted all attempts, provide a helpful error message
if (resolutionFailedOnce) {
// Localhost resolution issues detected
const errorMsg = createLocalhostErrorMessage(startPort, startPort + MAX_PORT_ATTEMPTS - 1);
throw new Error(errorMsg);
} else {
// Normal case - all ports occupied
const errorMsg = createNoPortsAvailableMessage(startPort, MAX_PORT_ATTEMPTS, false);
throw new Error(errorMsg);
}
}

async function startServer(flags: string[] = []): Promise<ChildProcessWithoutNullStreams> {
Expand All @@ -725,7 +730,8 @@ async function startServer(flags: string[] = []): Promise<ChildProcessWithoutNul

actualPort = await findAvailablePort(defaultPort);

let serverArgs: string[] = ['--listen-addr=localhost', `--port=${actualPort}`];
console.info(`Using host address: ${actualHost}`);
let serverArgs: string[] = [`--listen-addr=${actualHost}`, `--port=${actualPort}`];
if (!!args.kubeconfig) {
serverArgs = serverArgs.concat(['--kubeconfig', args.kubeconfig]);
}
Expand Down Expand Up @@ -1547,10 +1553,10 @@ function startElectron() {
mainWindow = null;
});

// Workaround to cookies to be saved, since file:// protocal and localhost:port
// Workaround to cookies to be saved, since file:// protocol and host:port
// are treated as a cross site request.
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
if (details.url.startsWith(`http://localhost:${actualPort}`)) {
if (details.url.startsWith(`http://${actualHost}:${actualPort}`)) {
callback({
responseHeaders: {
...details.responseHeaders,
Expand Down Expand Up @@ -1812,7 +1818,7 @@ if (isHeadlessMode) {
attachServerEventHandlers(serverProcess);

// Give 1s for backend to start
setTimeout(() => shell.openExternal(`http://localhost:${actualPort}`), 1000);
setTimeout(() => shell.openExternal(`http://${actualHost}:${actualPort}`), 1000);
}
);
} else {
Expand Down
166 changes: 166 additions & 0 deletions app/electron/portUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, expect, it } from '@jest/globals';
import * as net from 'net';
import {
checkPortAvailability,
createLocalhostErrorMessage,
createNoPortsAvailableMessage,
isPortAvailableOnHost,
} from './portUtils';

describe('portUtils', () => {
describe('isPortAvailableOnHost', () => {
it('should return available=true for an available port on localhost', async () => {
// Let OS choose a free port
const server = net.createServer();
const port = await new Promise<number>(resolve => {
server.listen(0, 'localhost', () => {
const addr = server.address() as net.AddressInfo;
server.close(() => resolve(addr.port));
});
});

const result = await isPortAvailableOnHost(port, 'localhost');
expect(result.available).toBe(true);
expect(result.errorCode).toBeUndefined();
});

it('should return available=true for an available port on 127.0.0.1', async () => {
const server = net.createServer();
const port = await new Promise<number>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as net.AddressInfo;
server.close(() => resolve(addr.port));
});
});

const result = await isPortAvailableOnHost(port, '127.0.0.1');
expect(result.available).toBe(true);
expect(result.errorCode).toBeUndefined();
});

it('should return available=false with EADDRINUSE for a port that is in use', async () => {
// Create a server to occupy a port
const server = net.createServer();
const port = await new Promise<number>(resolve => {
server.listen(0, 'localhost', () => {
const addr = server.address() as net.AddressInfo;
resolve(addr.port);
});
});

try {
const result = await isPortAvailableOnHost(port, 'localhost');
expect(result.available).toBe(false);
expect(result.errorCode).toBe('EADDRINUSE');
} finally {
await new Promise<void>(resolve => {
server.close(() => resolve());
});
}
});
});

describe('checkPortAvailability', () => {
it('should return localhost when port is available on localhost', async () => {
const server = net.createServer();
const port = await new Promise<number>(resolve => {
server.listen(0, 'localhost', () => {
const addr = server.address() as net.AddressInfo;
server.close(() => resolve(addr.port));
});
});

const result = await checkPortAvailability(port);

expect(result.available).toBe(true);
expect(result.host).toBe('localhost');
expect(result.resolutionFailed).toBe(false);
expect(result.error).toBeUndefined();
});

it('should return unavailable without fallback when port is occupied (EADDRINUSE)', async () => {
// Occupy a port on all interfaces
const server = net.createServer();
const port = await new Promise<number>(resolve => {
server.listen(0, '0.0.0.0', () => {
const addr = server.address() as net.AddressInfo;
resolve(addr.port);
});
});

try {
const result = await checkPortAvailability(port);

expect(result.available).toBe(false);
expect(result.resolutionFailed).toBe(false); // Port occupied, not resolution failure
expect(result.errorCode).toBe('EADDRINUSE');
} finally {
await new Promise<void>(resolve => {
server.close(() => resolve());
});
}
});
});

describe('createLocalhostErrorMessage', () => {
it('should create a helpful error message with troubleshooting steps', () => {
const message = createLocalhostErrorMessage(4466, 4565);

expect(message).toContain('4466-4565');
expect(message).toContain('localhost');
expect(message).toContain('127.0.0.1');
expect(message).toContain('::1');
expect(message).toContain('Troubleshooting steps');
expect(message).toContain('/etc/hosts');
expect(message).toContain('ping localhost');
expect(message).toContain('lsof');
expect(message).toContain('--port flag');
});
});

describe('createNoPortsAvailableMessage', () => {
it('should create error message without resolution failure note', () => {
const message = createNoPortsAvailableMessage(4466, 100, false);

expect(message).toContain('4466');
expect(message).toContain('4565'); // 4466 + 100 - 1
expect(message).toContain('100 attempts');
expect(message).not.toContain('resolution failed');
expect(message).not.toContain('fallback');
});

it('should create error message with resolution failure note', () => {
const message = createNoPortsAvailableMessage(4466, 100, true);

expect(message).toContain('4466');
expect(message).toContain('4565');
expect(message).toContain('100 attempts');
expect(message).toContain('resolution failed');
expect(message).toContain('fallback');
});

it('should calculate correct port range', () => {
const message = createNoPortsAvailableMessage(5000, 50, true);

expect(message).toContain('5000');
expect(message).toContain('5049'); // 5000 + 50 - 1
});
});
});

Loading
Loading