Add port utilities; use port checks in wiki

Introduce port helper functions and tests: add src/services/libs/port.ts with isPortAvailable and findAvailablePort (tries start port, +10, then incremental attempts) and corresponding Vitest tests in src/services/libs/__tests__/port.test.ts.

Integrate pre-flight port availability checks into src/services/wiki/index.ts: import findAvailablePort, verify and choose an available port before starting a wiki, update workspace metadata and URLs when a different port is selected, and log/report failures. Add an emergency fallback on EADDRINUSE at runtime to attempt finding and applying an alternative port. Also adjust the worker import comment to use @ts-ignore for the Vite nodeWorker import.
This commit is contained in:
linonetwo 2026-02-09 00:44:48 +08:00
parent c30a4cc05c
commit fe0f0c9700
3 changed files with 159 additions and 11 deletions

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { findAvailablePort, isPortAvailable } from '../port';
describe('Port Utilities', () => {
describe('isPortAvailable', () => {
it('should return true for available high-numbered port', async () => {
const available = await isPortAvailable(59999);
expect(available).toBe(true);
});
it('should handle occupied ports gracefully', async () => {
const result = await isPortAvailable(80);
expect(typeof result).toBe('boolean');
});
});
describe('findAvailablePort', () => {
it('should return the same port if available', async () => {
const testPort = 59998;
const result = await findAvailablePort(testPort);
expect(result).toBe(testPort);
});
it('should find an available port when starting from a commonly used port', async () => {
const testPort = 5212;
const result = await findAvailablePort(testPort);
expect(result).not.toBeNull();
if (result !== null) {
expect(result).toBeGreaterThanOrEqual(testPort);
}
});
it('should eventually find an available port', async () => {
const testPort = 59997;
const result = await findAvailablePort(testPort);
expect(result).not.toBeNull();
expect(typeof result).toBe('number');
});
});
});

62
src/services/libs/port.ts Normal file
View file

@ -0,0 +1,62 @@
import { Server } from 'node:net';
/**
* Check if a port is available on the local machine
* Optimized for minimal overhead - closes server immediately after binding
* @param port The port number to check
* @returns true if port is available, false if in use
*/
export async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = new Server();
server.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
resolve(false);
} else {
// Other errors treated as unavailable
resolve(false);
}
});
server.once('listening', () => {
// Close with callback to ensure cleanup before resolving
server.close(() => {
resolve(true);
});
});
// Bind to 0.0.0.0 to check all network interfaces
server.listen(port, '0.0.0.0');
});
}
/**
* Find an available port starting from the given port
* First tries port, then port+10, then keeps incrementing by 1
* @param startPort The port to start checking from
* @param maxAttempts Maximum number of attempts (default 100)
* @returns An available port number, or null if none found
*/
export async function findAvailablePort(startPort: number, maxAttempts = 100): Promise<number | null> {
// First try the original port
if (await isPortAvailable(startPort)) {
return startPort;
}
// Then try +10
const portPlusTen = startPort + 10;
if (await isPortAvailable(portPlusTen)) {
return portPlusTen;
}
// Then increment by 1 from +10
for (let attempt = 1; attempt < maxAttempts; attempt++) {
const portToTry = portPlusTen + attempt;
if (await isPortAvailable(portToTry)) {
return portToTry;
}
}
return null;
}

View file

@ -1,3 +1,4 @@
import { findAvailablePort } from '@services/libs/port';
import { createWorkerProxy, terminateWorker } from '@services/libs/workerAdapter';
import { dialog, shell } from 'electron';
import { attachWorker } from 'electron-ipc-cat/server';
@ -6,7 +7,8 @@ import { copy, exists, mkdir, mkdirs, pathExists, readdir, readFile } from 'fs-e
import { inject, injectable } from 'inversify';
import path from 'path';
import { Worker } from 'worker_threads';
// @ts-expect-error - Vite worker import with ?nodeWorker query
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Vite worker import with ?nodeWorker query
import WikiWorkerFactory from './wikiWorker/index?nodeWorker';
import { container } from '@services/container';
@ -118,7 +120,8 @@ export class Wiki implements IWikiService {
logger.error('Try to start wiki, but workspace is not a wiki workspace', { workspace, workspaceID });
return;
}
const { port, rootTiddler, readOnlyMode, tokenAuth, homeUrl, lastUrl, https, excludedPlugins, isSubWiki, wikiFolderLocation, name, enableHTTPAPI, authToken } = workspace;
const { rootTiddler, readOnlyMode, tokenAuth, https, excludedPlugins, isSubWiki, wikiFolderLocation, name, enableHTTPAPI, authToken } = workspace;
let { port, homeUrl, lastUrl } = workspace;
logger.debug('startWiki: Got workspace from workspaceService', {
workspaceID,
name,
@ -131,6 +134,39 @@ export class Wiki implements IWikiService {
logger.error('Try to start wiki, but workspace is sub wiki', { workspace, workspaceID });
return;
}
// Check if port is available before starting wiki
const availablePort = await findAvailablePort(port);
if (availablePort === null) {
logger.error('Could not find available port for wiki', { workspaceID, requestedPort: port });
await workspaceService.updateMetaData(workspaceID, {
isLoading: false,
didFailLoadErrorMessage: `Could not find available port starting from ${port}`,
});
return;
}
// Update workspace settings if port changed
if (availablePort !== port) {
logger.info('Port conflict detected, using alternative port', {
workspaceID,
originalPort: port,
newPort: availablePort,
});
const portChange = {
port: availablePort,
homeUrl: homeUrl.replace(`:${port}`, `:${availablePort}`),
lastUrl: lastUrl?.replace(`:${port}`, `:${availablePort}`) ?? null,
};
await workspaceService.update(workspaceID, portChange, true);
// Update local variables with new port
port = availablePort;
homeUrl = portChange.homeUrl;
lastUrl = portChange.lastUrl;
}
// wiki server is about to boot, but our webview is just start loading, wait for `view.webContents.on('did-stop-loading'` to set this to false
await workspaceService.updateMetaData(workspaceID, { isLoading: true });
if (tokenAuth && authToken) {
@ -287,16 +323,26 @@ export class Wiki implements IWikiService {
logger.info('Realigned view after plugin error', { workspaceID, function: 'startWiki' });
}
// fix "message":"listen EADDRINUSE: address already in use 0.0.0.0:5212"
// Port availability check should have prevented EADDRINUSE, but handle as fallback
if (errorMessage.includes('EADDRINUSE')) {
const portChange = {
port: port + 1,
homeUrl: homeUrl.replace(`:${port}`, `:${port + 1}`),
lastUrl: lastUrl?.replace(`:${port}`, `:${port + 1}`) ?? null,
};
await workspaceService.update(workspaceID, portChange, true);
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange }));
logger.warn('EADDRINUSE error despite pre-flight port check', {
workspaceID,
port,
errorMessage,
});
// Try to find another available port as emergency fallback
const emergencyPort = await findAvailablePort(port + 1);
if (emergencyPort !== null) {
const portChange = {
port: emergencyPort,
homeUrl: homeUrl.replace(`:${port}`, `:${emergencyPort}`),
lastUrl: lastUrl?.replace(`:${port}`, `:${emergencyPort}`) ?? null,
};
await workspaceService.update(workspaceID, portChange, true);
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange }));
} else {
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace }));
}
return;
}