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>
Signed| @@ -55,6 +55,8 @@ import { router as volcengineRouter } from './endpoints/volcengine.js'; | ||
| 55 | 55 | * @typedef {object} ServerStartupResult |
| 56 | 56 | * @property {boolean} v6Failed If the server failed to start on IPv6 |
| 57 | 57 | * @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 | 60 | * @property {boolean} useIPv6 If use IPv6 |
| 59 | 61 | * @property {boolean} useIPv4 If use IPv4 |
| 60 | 62 | */ |
| @@ -207,6 +209,37 @@ export class ServerStartup { | ||
| 207 | 209 | } |
| 208 | 210 | |
| 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 | 243 | * Checks if SSL options are valid. If not, it will print an error message and exit the process. |
| 211 | 244 | * @returns {void} |
| 212 | 245 | */ |
| @@ -287,11 +320,13 @@ export class ServerStartup { | ||
| 287 | 320 | * Starts the server using http or https depending on config |
| 288 | 321 | * @param {boolean} useIPv6 If use IPv6 |
| 289 | 322 | * @param {boolean} useIPv4 If use IPv4 |
| 290 | 323 | * @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 | 325 | async #startHTTPorHTTPS(useIPv6, useIPv4) { |
| 293 | 326 | let v6Failed = false; |
| 294 | 327 | let v4Failed = false; |
| 328 | + let v6Error; | |
| 329 | + let v4Error; | |
| 295 | 330 | |
| 296 | 331 | const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this); |
| 297 | 332 | |
| @@ -300,9 +335,14 @@ export class ServerStartup { | ||
| 300 | 335 | await createFunc(this.cliArgs.getIPv6ListenUrl(), 6); |
| 301 | 336 | } catch (error) { |
| 302 | 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 | 341 | console.error(error); |
| 342 | + } | |
| 304 | 343 | |
| 305 | 344 | v6Failed = true; |
| 345 | + v6Error = error; | |
| 306 | 346 | } |
| 307 | 347 | } |
| 308 | 348 | |
| @@ -311,13 +351,18 @@ export class ServerStartup { | ||
| 311 | 351 | await createFunc(this.cliArgs.getIPv4ListenUrl(), 4); |
| 312 | 352 | } catch (error) { |
| 313 | 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 | 357 | console.error(error); |
| 358 | + } | |
| 315 | 359 | |
| 316 | 360 | v4Failed = true; |
| 361 | + v4Error = error; | |
| 317 | 362 | } |
| 318 | 363 | } |
| 319 | 364 | |
| 320 | 365 | return [v6Failed, v4Failed, v6Error, v4Error]; |
| 321 | 366 | } |
| 322 | 367 | |
| 323 | 368 | /** |
| @@ -325,16 +370,25 @@ export class ServerStartup { | ||
| 325 | 370 | * @param {ServerStartupResult} result The results of the server startup |
| 326 | 371 | * @returns {void} |
| 327 | 372 | */ |
| 328 | 373 | #handleServerListenFail({ v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }) { |
| 329 | 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 | 378 | this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled'); |
| 331 | 379 | } |
| 332 | 380 | |
| 333 | 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 | 385 | this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled'); |
| 335 | 386 | } |
| 336 | 387 | |
| 337 | 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 | 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 | 442 | process.exit(1); |
| 389 | 443 | } |
| 390 | 444 | |
| 391 | 445 | const [v6Failed, v4Failed, v6Error, v4Error] = await this.#startHTTPorHTTPS(useIPv6, useIPv4); |
| 392 | 446 | const result = { v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }; |
| 393 | 447 | this.#handleServerListenFail(result); |
| 394 | 448 | return result; |
| 395 | 449 | } |