Handle port conflicts during server startup (#5349) * Handle port conflicts during server startup * Fix return type of startHTTPorHTTPS * Update language in getAddressInUseMessage --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

4839c76fb556ac879f4333e49db762f22f52f6e3

Raymond Flanagan <tikthra@gmail.com>

Signed
1 files changed, +59 -5Showing whitespace changes
src/server-startup.js+59 -5
@@ -55,6 +55,8 @@ import { router as volcengineRouter } from './endpoints/volcengine.js';
5555 * @typedef {object} ServerStartupResult
5656 * @property {boolean} v6Failed If the server failed to start on IPv6
5757 * @property {boolean} v4Failed If the server failed to start on IPv4
58+ * @property {unknown} [v6Error] The IPv6 server startup error
59+ * @property {unknown} [v4Error] The IPv4 server startup error
5860 * @property {boolean} useIPv6 If use IPv6
5961 * @property {boolean} useIPv4 If use IPv4
6062 */
@@ -207,6 +209,37 @@ export class ServerStartup {
207209 }
208210
209211 /**
212+ * Checks if the error was caused by an occupied port.
213+ * @param {unknown} error
214+ * @returns {error is NodeJS.ErrnoException}
215+ */
216+ #isAddressInUseError(error) {
217+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EADDRINUSE';
218+ }
219+
220+ /**
221+ * Gets a readable listen address for an IP version.
222+ * @param {URL} url The URL to listen on
223+ * @param {number} ipVersion The IP version to use
224+ * @returns {string}
225+ */
226+ #getListenAddress(url, ipVersion) {
227+ const host = ipVersion === 6 ? urlHostnameToIPv6(url.hostname) : url.hostname;
228+ return `${host}:${Number(url.port || (this.cliArgs.ssl ? 443 : 80))}`;
229+ }
230+
231+ /**
232+ * Builds a user-facing error for an occupied port.
233+ * @param {URL} url The URL that failed to bind
234+ * @param {number} ipVersion The IP version that failed
235+ * @returns {string}
236+ */
237+ #getAddressInUseMessage(url, ipVersion) {
238+ const listenAddress = this.#getListenAddress(url, ipVersion);
239+ return `Address ${listenAddress} is already in use. Another SillyTavern instance may already be running. Stop the other process or change "port" in config.yaml.`;
240+ }
241+
242+ /**
210243 * Checks if SSL options are valid. If not, it will print an error message and exit the process.
211244 * @returns {void}
212245 */
@@ -287,11 +320,13 @@ export class ServerStartup {
287320 * Starts the server using http or https depending on config
288321 * @param {boolean} useIPv6 If use IPv6
289322 * @param {boolean} useIPv4 If use IPv4
290323 * @returns {Promise<[boolean, boolean, unknown, unknown]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively, and the corresponding errors
291324 */
292325 async #startHTTPorHTTPS(useIPv6, useIPv4) {
293326 let v6Failed = false;
294327 let v4Failed = false;
328+ let v6Error;
329+ let v4Error;
295330
296331 const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
297332
@@ -300,9 +335,14 @@ export class ServerStartup {
300335 await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
301336 } catch (error) {
302337 console.error('Warning: failed to start server on IPv6');
338+ if (this.#isAddressInUseError(error)) {
339+ console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv6ListenUrl(), 6));
340+ } else {
303341 console.error(error);
342+ }
304343
305344 v6Failed = true;
345+ v6Error = error;
306346 }
307347 }
308348
@@ -311,13 +351,18 @@ export class ServerStartup {
311351 await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
312352 } catch (error) {
313353 console.error('Warning: failed to start server on IPv4');
354+ if (this.#isAddressInUseError(error)) {
355+ console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv4ListenUrl(), 4));
356+ } else {
314357 console.error(error);
358+ }
315359
316360 v4Failed = true;
361+ v4Error = error;
317362 }
318363 }
319364
320365 return [v6Failed, v4Failed, v6Error, v4Error];
321366 }
322367
323368 /**
@@ -325,16 +370,25 @@ export class ServerStartup {
325370 * @param {ServerStartupResult} result The results of the server startup
326371 * @returns {void}
327372 */
328373 #handleServerListenFail({ v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }) {
329374 if (v6Failed && !useIPv4) {
375+ if (this.#isAddressInUseError(v6Error)) {
376+ this.#fatal('Error: Startup aborted because IPv6 is the only enabled protocol and its listen port is already in use.');
377+ }
330378 this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
331379 }
332380
333381 if (v4Failed && !useIPv6) {
382+ if (this.#isAddressInUseError(v4Error)) {
383+ this.#fatal('Error: Startup aborted because IPv4 is the only enabled protocol and its listen port is already in use.');
384+ }
334385 this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
335386 }
336387
337388 if (v6Failed && v4Failed) {
389+ if (this.#isAddressInUseError(v6Error) && this.#isAddressInUseError(v4Error)) {
390+ this.#fatal('Error: Failed to start server because the configured IPv6 and IPv4 listen ports are already in use.');
391+ }
338392 this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
339393 }
340394 }
@@ -388,8 +442,8 @@ export class ServerStartup {
388442 process.exit(1);
389443 }
390444
391445 const [v6Failed, v4Failed, v6Error, v4Error] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
392446 const result = { v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 };
393447 this.#handleServerListenFail(result);
394448 return result;
395449 }