Skip to content

Commit fd1ab69

Browse files
feat: find free port for gui server automatically (#718)
Co-authored-by: shadowusr
1 parent ea71144 commit fd1ab69

File tree

3 files changed

+96
-11
lines changed

3 files changed

+96
-11
lines changed

lib/gui/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type {CommanderStatic} from '@gemini-testing/commander';
2-
import chalk from 'chalk';
32
import opener from 'opener';
43

54
import * as server from './server';
6-
import {logger} from '../common-utils';
75
import * as utils from '../server-utils';
86

97
import type {ToolAdapter} from '../adapters/tool';
@@ -29,7 +27,6 @@ export interface ServerArgs {
2927
export default (args: ServerArgs): void => {
3028
server.start(args)
3129
.then(({url}: { url: string }) => {
32-
logger.log(`GUI is running at ${chalk.cyan(url)}`);
3330
args.cli.options.open && opener(url);
3431
})
3532
.catch((err: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any

lib/gui/listen-with-fallback.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type {Server as HttpServer} from 'http';
2+
import type {Express} from 'express';
3+
import {HEADERS_TIMEOUT, KEEP_ALIVE_TIMEOUT} from './constants';
4+
5+
interface ListenOptions {
6+
server: Express;
7+
hostname?: string;
8+
requestedPort?: number;
9+
}
10+
11+
interface ListenResult {
12+
actualPort: number;
13+
hostnameForUrl: string;
14+
}
15+
16+
const MAX_PORT_NUMBER = 65_535;
17+
const DEFAULT_PORT = 3000;
18+
19+
const listenOnPort = (server: Express, portToTry: number, hostname?: string): Promise<void> => {
20+
return new Promise((resolve, reject) => {
21+
// eslint-disable-next-line prefer-const
22+
let httpServer: HttpServer;
23+
24+
const handleError = (error: NodeJS.ErrnoException): void => {
25+
httpServer.removeListener('error', handleError);
26+
reject(error);
27+
};
28+
29+
const onListen = (): void => {
30+
resolve();
31+
};
32+
33+
httpServer = hostname
34+
? server.listen(portToTry, hostname, onListen)
35+
: server.listen(portToTry, onListen);
36+
httpServer.keepAliveTimeout = KEEP_ALIVE_TIMEOUT;
37+
httpServer.headersTimeout = HEADERS_TIMEOUT;
38+
39+
httpServer.once('error', handleError);
40+
});
41+
};
42+
43+
export const listenWithFallback = async ({server, hostname, requestedPort}: ListenOptions): Promise<ListenResult> => {
44+
const hostnameForUrl = hostname ?? 'localhost';
45+
const startPort = requestedPort ?? DEFAULT_PORT;
46+
47+
let currentPort = startPort;
48+
let isSuccess = false;
49+
50+
while (currentPort <= MAX_PORT_NUMBER) {
51+
try {
52+
await listenOnPort(server, currentPort, hostname);
53+
isSuccess = true;
54+
break;
55+
} catch (error) {
56+
const err = error as NodeJS.ErrnoException;
57+
58+
if (err.code !== 'EADDRINUSE') {
59+
throw err;
60+
}
61+
62+
if (currentPort >= MAX_PORT_NUMBER) {
63+
throw new Error(`Unable to find an available port to start GUI server: ${err.message}`);
64+
}
65+
66+
currentPort += 1;
67+
}
68+
}
69+
70+
if (!isSuccess) {
71+
throw new Error(`Unable to find an available port (tried all variants between ${startPort} and ${MAX_PORT_NUMBER})`);
72+
}
73+
74+
return {
75+
actualPort: currentPort,
76+
hostnameForUrl
77+
};
78+
};

lib/gui/server.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import path from 'path';
22
import express from 'express';
33
import {onExit} from 'signal-exit';
4-
import BluebirdPromise from 'bluebird';
54
import bodyParser from 'body-parser';
65
import {INTERNAL_SERVER_ERROR, OK} from 'http-codes';
76
import type {Config} from 'testplane';
7+
import {listenWithFallback} from './listen-with-fallback';
88

99
import {App} from './app';
10-
import {MAX_REQUEST_SIZE, KEEP_ALIVE_TIMEOUT, HEADERS_TIMEOUT} from './constants';
10+
import {MAX_REQUEST_SIZE} from './constants';
1111
import {logger} from '../common-utils';
1212
import {initPluginsRoutes} from './routes/plugins';
1313
import {BrowserFeature, Feature, ToolName} from '../constants';
@@ -19,6 +19,7 @@ import type {TestplaneToolAdapter} from '../adapters/tool/testplane';
1919
import type {ToolRunnerTree} from './tool-runner';
2020
import type {TestplaneConfigAdapter} from '../adapters/config/testplane';
2121
import type {UpdateTimeTravelSettingsRequest, UpdateTimeTravelSettingsResponse} from '../types';
22+
import chalk from 'chalk';
2223

2324
interface CustomGuiError {
2425
response: {
@@ -275,14 +276,23 @@ export const start = async (args: ServerArgs): Promise<ServerReadyData> => {
275276

276277
await app.initialize();
277278

278-
const {port, hostname} = args.cli.options;
279-
await BluebirdPromise.fromCallback((callback) => {
280-
const httpServer = server.listen(port, hostname, callback as () => void);
281-
httpServer.keepAliveTimeout = KEEP_ALIVE_TIMEOUT;
282-
httpServer.headersTimeout = HEADERS_TIMEOUT;
279+
const {port: requestedPort, hostname} = args.cli.options;
280+
281+
const {actualPort, hostnameForUrl} = await listenWithFallback({
282+
server,
283+
hostname,
284+
requestedPort
283285
});
284286

285-
const data = {url: `http://${hostname}:${port}`};
287+
const listeningMessage = `GUI is running at ${chalk.cyan(`http://${hostnameForUrl}:${actualPort}`)}`;
288+
289+
if (requestedPort !== undefined && actualPort !== requestedPort) {
290+
logger.log(`${listeningMessage}. This port was chosen because ${hostnameForUrl}:${requestedPort} is busy.`);
291+
} else {
292+
logger.log(listeningMessage);
293+
}
294+
295+
const data = {url: `http://${hostnameForUrl}:${actualPort}`};
286296

287297
await guiApi.serverReady(data);
288298

0 commit comments

Comments
 (0)