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';
55 * @typedef {object} ServerStartupResult55 * @typedef {object} ServerStartupResult
56 * @property {boolean} v6Failed If the server failed to start on IPv656 * @property {boolean} v6Failed If the server failed to start on IPv6
57 * @property {boolean} v4Failed If the server failed to start on IPv457 * @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
58 * @property {boolean} useIPv6 If use IPv660 * @property {boolean} useIPv6 If use IPv6
59 * @property {boolean} useIPv4 If use IPv461 * @property {boolean} useIPv4 If use IPv4
60 */62 */
@@ -207,6 +209,37 @@ export class ServerStartup {
207 }209 }
208210
209 /**211 /**
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 /**
210 * Checks if SSL options are valid. If not, it will print an error message and exit the process.243 * Checks if SSL options are valid. If not, it will print an error message and exit the process.
211 * @returns {void}244 * @returns {void}
212 */245 */
@@ -287,11 +320,13 @@ export class ServerStartup {
287 * Starts the server using http or https depending on config320 * Starts the server using http or https depending on config
288 * @param {boolean} useIPv6 If use IPv6321 * @param {boolean} useIPv6 If use IPv6
289 * @param {boolean} useIPv4 If use IPv4322 * @param {boolean} useIPv4 If use IPv4
290 * @returns {Promise<[boolean, boolean]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively323 * @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
291 */324 */
292 async #startHTTPorHTTPS(useIPv6, useIPv4) {325 async #startHTTPorHTTPS(useIPv6, useIPv4) {
293 let v6Failed = false;326 let v6Failed = false;
294 let v4Failed = false;327 let v4Failed = false;
328 let v6Error;
329 let v4Error;
295330
296 const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);331 const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
297332
@@ -300,9 +335,14 @@ export class ServerStartup {
300 await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);335 await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
301 } catch (error) {336 } catch (error) {
302 console.error('Warning: failed to start server on IPv6');337 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 {
303 console.error(error);341 console.error(error);
342 }
304343
305 v6Failed = true;344 v6Failed = true;
345 v6Error = error;
306 }346 }
307 }347 }
308348
@@ -311,13 +351,18 @@ export class ServerStartup {
311 await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);351 await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
312 } catch (error) {352 } catch (error) {
313 console.error('Warning: failed to start server on IPv4');353 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 {
314 console.error(error);357 console.error(error);
358 }
315359
316 v4Failed = true;360 v4Failed = true;
361 v4Error = error;
317 }362 }
318 }363 }
319364
320 return [v6Failed, v4Failed];365 return [v6Failed, v4Failed, v6Error, v4Error];
321 }366 }
322367
323 /**368 /**
@@ -325,16 +370,25 @@ export class ServerStartup {
325 * @param {ServerStartupResult} result The results of the server startup370 * @param {ServerStartupResult} result The results of the server startup
326 * @returns {void}371 * @returns {void}
327 */372 */
328 #handleServerListenFail({ v6Failed, v4Failed, useIPv6, useIPv4 }) {373 #handleServerListenFail({ v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }) {
329 if (v6Failed && !useIPv4) {374 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 }
330 this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');378 this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
331 }379 }
332380
333 if (v4Failed && !useIPv6) {381 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 }
334 this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');385 this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
335 }386 }
336387
337 if (v6Failed && v4Failed) {388 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 }
338 this.#fatal('Error: Failed to start server on both IPv6 and IPv4');392 this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
339 }393 }
340 }394 }
@@ -388,8 +442,8 @@ export class ServerStartup {
388 process.exit(1);442 process.exit(1);
389 }443 }
390444
391 const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);445 const [v6Failed, v4Failed, v6Error, v4Error] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
392 const result = { v6Failed, v4Failed, useIPv6, useIPv4 };446 const result = { v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 };
393 this.#handleServerListenFail(result);447 this.#handleServerListenFail(result);
394 return result;448 return result;
395 }449 }