diff --git a/src/services/libs/__tests__/port.test.ts b/src/services/libs/__tests__/port.test.ts new file mode 100644 index 00000000..d91e34fd --- /dev/null +++ b/src/services/libs/__tests__/port.test.ts @@ -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'); + }); + }); +}); diff --git a/src/services/libs/port.ts b/src/services/libs/port.ts new file mode 100644 index 00000000..046f6fb5 --- /dev/null +++ b/src/services/libs/port.ts @@ -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 { + 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 { + // 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; +} diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index e849dc8f..2d870d51 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -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; }