Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
70 changes: 37 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 localhostFailedOnce = 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,40 @@ 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;
if (result.localhostFailed && !localhostFailedOnce) {
localhostFailedOnce = true;
console.warn(
`Note: 'localhost' resolution failed, 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;
}

if (result.localhostFailed && !localhostFailedOnce) {
localhostFailedOnce = 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 (localhostFailedOnce) {
// 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 +728,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 +1551,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 +1816,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
148 changes: 148 additions & 0 deletions app/electron/portUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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 true for an available port on localhost', async () => {
// Use a high port that's likely to be available
const port = 50000 + Math.floor(Math.random() * 1000);
const result = await isPortAvailableOnHost(port, 'localhost');
expect(result).toBe(true);
});

it('should return true for an available port on 127.0.0.1', async () => {
const port = 50000 + Math.floor(Math.random() * 1000);
const result = await isPortAvailableOnHost(port, '127.0.0.1');
expect(result).toBe(true);
});

it('should return false for a port that is in use', async () => {
// Create a server to occupy a port
const port = 50000 + Math.floor(Math.random() * 1000);
const server = net.createServer();
await new Promise<void>(resolve => {
server.listen(port, 'localhost', () => resolve());
});

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

describe('checkPortAvailability', () => {
it('should return localhost when port is available on localhost', async () => {
const port = 50000 + Math.floor(Math.random() * 1000);
const result = await checkPortAvailability(port);

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

it('should return 127.0.0.1 when localhost fails but 127.0.0.1 succeeds', async () => {
// This test is hard to simulate without mocking, so we'll test the logic indirectly
// by checking that the function handles both hosts
const port = 50000 + Math.floor(Math.random() * 1000);
const result = await checkPortAvailability(port);

expect(result.available).toBe(true);
expect(['localhost', '127.0.0.1']).toContain(result.host);
});

it('should return unavailable when port is occupied on both hosts', async () => {
const port = 50000 + Math.floor(Math.random() * 1000);

// Occupy the port on all interfaces
const server = net.createServer();
await new Promise<void>(resolve => {
server.listen(port, '0.0.0.0', () => resolve());
});

try {
const result = await checkPortAvailability(port);

expect(result.available).toBe(false);
expect(result.localhostFailed).toBe(true);
expect(result.error).toContain('not available');
} 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('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 localhost 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('localhost');
expect(message).not.toContain('fallback');
});

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

expect(message).toContain('4466');
expect(message).toContain('4565');
expect(message).toContain('100 attempts');
expect(message).toContain('localhost');
expect(message).toContain('127.0.0.1');
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