From 3d7a537db15c74bd2efab2fec8c8e7c643090fcb Mon Sep 17 00:00:00 2001 From: xiaohongrsx Date: Wed, 3 Jun 2026 03:00:34 +0800 Subject: [PATCH 1/2] fix(ui-server): auto-fallback to a random port on EADDRINUSE (#82) The online installer launches the Web UI via `npm run start:built`, which runs ui/server/index.js directly. That path called `server.listen()` with no error handler, so a busy port (e.g. 3000/3001 already taken by other Node apps) crashed the process with an uncaught exception and aborted the install instead of recovering. Add listenWithPortFallback(): on EADDRINUSE, retry on a random high port (20000-59999) up to 5 times, then exit with a clear message. Random ports are used instead of preferred+1 because adjacent ports are often held by the same multi-port app. The actually-bound port is logged, used for the browser auto-open, and written back to process.env.SERVER_PORT so modules that self-reference it (e.g. routes/taskmaster.js) hit the right port. Co-Authored-By: Claude Opus 4.8 --- ui/server/index.js | 65 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/ui/server/index.js b/ui/server/index.js index 5cd8408a..6f49217b 100755 --- a/ui/server/index.js +++ b/ui/server/index.js @@ -2941,6 +2941,52 @@ const HOST = process.env.HOST || '0.0.0.0'; const DISPLAY_HOST = getConnectableHost(HOST); const VITE_PORT = process.env.VITE_PORT || 5173; +const PORT_FALLBACK_ATTEMPTS = 5; + +// Pick a random high port in the 20000–59999 range. Random (rather than the +// preferred port + 1) because adjacent ports are frequently held by the same +// multi-port app that already took the preferred one. +function pickRandomHighPort() { + return 20000 + Math.floor(Math.random() * 40000); +} + +// Listen on `preferredPort`; on EADDRINUSE retry on random high ports up to +// PORT_FALLBACK_ATTEMPTS times. Resolves with the actually-bound port, or null +// if every attempt was in use. Non-EADDRINUSE errors reject — real failures +// (bad host, permissions) must not be silently retried. +function listenWithPortFallback(srv, preferredPort, host) { + let port = preferredPort; + let attempt = 0; + return new Promise((resolve, reject) => { + const tryListen = () => { + attempt += 1; + const onError = (err) => { + srv.removeListener('listening', onListening); + if (err && err.code === 'EADDRINUSE') { + if (attempt >= PORT_FALLBACK_ATTEMPTS) { + resolve(null); + return; + } + const nextPort = pickRandomHighPort(); + console.log(`${c.warn('[WARN]')} Port ${port} is in use; retrying on random port ${nextPort} (attempt ${attempt}/${PORT_FALLBACK_ATTEMPTS})...`); + port = nextPort; + setImmediate(tryListen); + return; + } + reject(err); + }; + const onListening = () => { + srv.removeListener('error', onError); + resolve(srv.address().port); + }; + srv.once('error', onError); + srv.once('listening', onListening); + srv.listen(port, host); + }; + tryListen(); + }); +} + async function ensureLocalUserWhenAuthDisabled() { if (!DISABLE_LOCAL_AUTH || userDb.hasUsers()) { return; @@ -2970,12 +3016,21 @@ async function startServer() { console.log(''); if (isProduction) { - console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`); + console.log(`${c.info('[INFO]')} Starting in production mode...`); } else { console.log(`${c.info('[INFO]')} No production frontend build found; development mode expects Vite at http://${DISPLAY_HOST}:${VITE_PORT}`); } - server.listen(SERVER_PORT, HOST, async () => { + const boundPort = await listenWithPortFallback(server, Number(SERVER_PORT), HOST); + if (boundPort === null) { + console.error(`${c.warn('[ERROR]')} Could not bind a port after ${PORT_FALLBACK_ATTEMPTS} attempts (preferred ${SERVER_PORT}). All tried ports were in use. Set SERVER_PORT to a free port and retry.`); + process.exit(1); + } + // Sync the actually-bound port back to the env so other modules + // that self-reference SERVER_PORT (e.g. routes/taskmaster.js) hit + // the right port after a fallback. + process.env.SERVER_PORT = String(boundPort); + { const appInstallPath = path.join(__dirname, '..'); console.log(''); @@ -2983,7 +3038,7 @@ async function startServer() { console.log(` ${c.bright('PilotDeck Server - Ready')}`); console.log(c.dim('═'.repeat(63))); console.log(''); - console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`); + console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + boundPort)}`); console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`); console.log(`${c.tip('[TIP]')} Run "pilotdeck status" for full configuration details`); console.log(''); @@ -2994,7 +3049,7 @@ async function startServer() { process.env.PILOTDECK_DESKTOP === '1' || process.env.PILOTDECK_SKIP_BROWSER_OPEN === '1'; if (!skipAutoOpen) { - const serverUrl = `http://${DISPLAY_HOST === '0.0.0.0' ? 'localhost' : DISPLAY_HOST}:${SERVER_PORT}`; + const serverUrl = `http://${DISPLAY_HOST === '0.0.0.0' ? 'localhost' : DISPLAY_HOST}:${boundPort}`; const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; @@ -3022,7 +3077,7 @@ async function startServer() { process.emit('pilotdeck:config-broadcast', payload); }, }); - }); + } } }); From f9f1f7ae4df19e451139d84b05d4fb5978e0bdcf Mon Sep 17 00:00:00 2001 From: xiaohongrsx Date: Wed, 3 Jun 2026 03:02:31 +0800 Subject: [PATCH 2/2] chore: enforce LF line endings for shell scripts Force *.sh (and docker-entrypoint.sh explicitly) to LF via .gitattributes. On Windows checkouts with core.autocrlf=true, git rewrote the Docker entrypoint to CRLF, which made the Linux container fail to start with `/usr/bin/env: 'bash\r': No such file or directory` and loop on restart. Pinning eol=lf prevents the regression on every clone/pull. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 2caf3224..e15d10ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,7 @@ ui/src/assets/** !filter !diff !merge text=auto # Desktop app icon sources are tiny; avoid LFS (OpenBMB budget) and release-time smudge failures apps/desktop/assets/** !filter !diff !merge text=auto + +# Shell scripts MUST stay LF — CRLF breaks the Docker entrypoint (`bash\r` not found) +*.sh text eol=lf +docker-entrypoint.sh text eol=lf