diff --git a/main/kds-server.ts b/main/kds-server.ts index ccebe630..d132973a 100644 --- a/main/kds-server.ts +++ b/main/kds-server.ts @@ -580,85 +580,96 @@ export function startKdsServer(): Promise { res.status(500).json({ error: 'Internal server error' }); }); - let currentKdsPort = KDS_PORT; + const baseKdsPort = parseInt(process.env.KDS_PORT || '3002', 10); + let currentKdsPort = baseKdsPort; let attempts = 0; - let listeningServer: http.Server; - const onListening = () => { - if (stopping) { - try { listeningServer.close(); } catch { return; } - return; - } - startReject = null; - const address = listeningServer.address(); - activeKdsPort = address && typeof address !== 'string' ? address.port : currentKdsPort; - console.log(`[KDS Server] HTTP server running on http://localhost:${activeKdsPort}`); - - if (listeningServer) { - // noServer + a manual 'upgrade' handler so a disabled KDS can 404 the - // upgrade instead of completing it — see main/server.ts for the same - // pattern on the primary API server (issue #133). - const wss = new WebSocketServer({ noServer: true }); - kdsWss = wss; - setupKdsWebSocket(wss); - - listeningServer.on('upgrade', (request, socket, head) => { - const pathname = (request.url || '').split('?')[0]; - if (pathname !== '/kds') { - socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); - socket.destroy(); - return; - } - - if (isDatabaseMaintenanceActive()) { - socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); - socket.destroy(); - return; - } + const listeningServer = http.createServer(app); + kdsServer = listeningServer; + installHttpShutdownTracking(listeningServer); - if (!isKdsEnabled()) { - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.destroy(); - return; - } + const tryListen = () => { + const attemptedPort = currentKdsPort; + const onListening = () => { + if (stopping) { + try { listeningServer.close(); } catch { return; } + return; + } + startReject = null; + listeningServer.off('error', onError); + const address = listeningServer.address(); + activeKdsPort = address && typeof address !== 'string' ? address.port : attemptedPort; + console.log(`[KDS Server] HTTP server running on http://localhost:${activeKdsPort}`); + + if (listeningServer) { + // noServer + a manual 'upgrade' handler so a disabled KDS can 404 the + // upgrade instead of completing it — see main/server.ts for the same + // pattern on the primary API server (issue #133). + const wss = new WebSocketServer({ noServer: true }); + kdsWss = wss; + setupKdsWebSocket(wss); + + listeningServer.on('upgrade', (request, socket, head) => { + const pathname = (request.url || '').split('?')[0]; + if (pathname !== '/kds') { + socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } - try { - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit('connection', ws, request); - }); - } catch (error) { - console.error('[KDS Server] WebSocket upgrade failed:', error); - socket.destroy(); - } - }); + if (isDatabaseMaintenanceActive()) { + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } - console.log(`[KDS Server] WebSocket running on ws://localhost:${activeKdsPort}/kds`); - } + if (!isKdsEnabled()) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } - resolve(); - }; + try { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } catch (error) { + console.error('[KDS Server] WebSocket upgrade failed:', error); + socket.destroy(); + } + }); - listeningServer = app.listen(currentKdsPort, '0.0.0.0', onListening); - kdsServer = listeningServer; - installHttpShutdownTracking(listeningServer); + console.log(`[KDS Server] WebSocket running on ws://localhost:${activeKdsPort}/kds`); + } - listeningServer.on('error', (err: NodeJS.ErrnoException) => { - if (stopping) return; - if (err.code === 'EADDRINUSE') { - attempts++; - if (attempts >= 10) { - const errorMsg = `[KDS Server] Failed to bind to any port after 10 attempts starting from ${KDS_PORT}`; - console.error(errorMsg); - reject(new Error(errorMsg)); + resolve(); + }; + + const onError = (err: NodeJS.ErrnoException) => { + if (stopping) return; + listeningServer.off('listening', onListening); + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + attempts++; + if (attempts >= 10) { + const errorMsg = `[KDS Server] Failed to bind to any port after 10 attempts starting from ${baseKdsPort}`; + console.error(errorMsg); + reject(new Error(errorMsg)); + return; + } + currentKdsPort++; + console.log(`[KDS Server] Port ${attemptedPort} in use (${err.code}), trying ${currentKdsPort}`); + tryListen(); return; } - currentKdsPort++; - console.log(`[KDS Server] Port ${currentKdsPort - 1} in use, trying ${currentKdsPort}`); - listeningServer.listen(currentKdsPort, '0.0.0.0', onListening); - } else { reject(err); - } - }); + }; + + listeningServer.once('listening', onListening); + listeningServer.once('error', onError); + listeningServer.listen(attemptedPort, '0.0.0.0'); + }; + + tryListen(); }); } diff --git a/main/server-app.ts b/main/server-app.ts index 954e7126..690c88c8 100644 --- a/main/server-app.ts +++ b/main/server-app.ts @@ -295,7 +295,8 @@ export function startServerApp(): Promise { res.status(500).json({ error: 'Internal server error' }); }); - let currentPort = SERVER_APP_PORT; + const baseServerAppPort = parseInt(process.env.SERVER_APP_PORT || String(SERVER_APP_PORT), 10); + let currentPort = baseServerAppPort; let attempts = 0; const listeningServer = http.createServer(app); serverApp = listeningServer; @@ -317,16 +318,16 @@ export function startServerApp(): Promise { const onError = (err: NodeJS.ErrnoException) => { if (stopping) return; listeningServer.off('listening', onListening); - if (err.code === 'EADDRINUSE') { + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { attempts++; if (attempts >= 10) { - const errorMsg = `[Server App] Failed to bind to any port after 10 attempts starting from ${SERVER_APP_PORT}`; + const errorMsg = `[Server App] Failed to bind to any port after 10 attempts starting from ${baseServerAppPort}`; console.error(errorMsg); reject(new Error(errorMsg)); return; } currentPort++; - console.log(`[Server App] Port ${attemptedPort} in use, trying ${currentPort}`); + console.log(`[Server App] Port ${attemptedPort} in use (${err.code}), trying ${currentPort}`); tryListen(); return; } diff --git a/main/server.ts b/main/server.ts index c6f58ba5..e98c7b59 100644 --- a/main/server.ts +++ b/main/server.ts @@ -273,96 +273,108 @@ export function startServer(): Promise { res.status(status).json({ error: status >= 500 ? 'Internal server error' : (err.message || 'Client error') }); }); - let currentPort = PORT; + const basePort = parseInt(process.env.PORT || '3001', 10); + let currentPort = basePort; let attempts = 0; - let listeningServer: http.Server; - const onListening = () => { - if (stopping) { - try { listeningServer.close(); } catch { return; } - return; - } - startReject = null; - const address = listeningServer.address(); - activePort = address && typeof address !== 'string' ? address.port : currentPort; - console.log(`[Server] HTTP server running on http://localhost:${activePort}`); - - if (listeningServer) { - // noServer + a manual 'upgrade' handler (rather than passing `server` - // straight to WebSocketServer) so a disabled KDS can 404 the upgrade - // instead of completing it — checked fresh on every request since - // kds_enabled can change at runtime without a restart (issue #133). - const websocketServer = new WebSocketServer({ noServer: true }); - wss = websocketServer; - setupKdsWebSocket(websocketServer); - - listeningServer.on('upgrade', (request, socket, head) => { - const pathname = (request.url || '').split('?')[0]; - if (pathname !== '/kds') { - socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); - socket.destroy(); - return; - } + const listeningServer = http.createServer(app); + server = listeningServer; + installHttpShutdownTracking(listeningServer); - if (isDatabaseMaintenanceActive()) { - socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); - socket.destroy(); - return; - } + const tryListen = () => { + const attemptedPort = currentPort; + const onListening = () => { + if (stopping) { + try { listeningServer.close(); } catch { return; } + return; + } + startReject = null; + listeningServer.off('error', onError); + const address = listeningServer.address(); + activePort = address && typeof address !== 'string' ? address.port : attemptedPort; + console.log(`[Server] HTTP server running on http://localhost:${activePort}`); + + if (listeningServer) { + // noServer + a manual 'upgrade' handler (rather than passing `server` + // straight to WebSocketServer) so a disabled KDS can 404 the upgrade + // instead of completing it — checked fresh on every request since + // kds_enabled can change at runtime without a restart (issue #133). + const websocketServer = new WebSocketServer({ noServer: true }); + wss = websocketServer; + setupKdsWebSocket(websocketServer); + + listeningServer.on('upgrade', (request, socket, head) => { + const pathname = (request.url || '').split('?')[0]; + if (pathname !== '/kds') { + socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } - if (!isKdsEnabled()) { - // Pretend the endpoint doesn't exist rather than confirming it's - // just disabled — less to probe from a stale/misconfigured KDS - // device on the LAN (issue #133). - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.destroy(); - return; - } + if (isDatabaseMaintenanceActive()) { + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } - try { - websocketServer.handleUpgrade(request, socket, head, (ws) => { - websocketServer.emit('connection', ws, request); - }); - } catch (error) { - console.error('[Server] KDS WebSocket upgrade failed:', error); - socket.destroy(); - } - }); + if (!isKdsEnabled()) { + // Pretend the endpoint doesn't exist rather than confirming it's + // just disabled — less to probe from a stale/misconfigured KDS + // device on the LAN (issue #133). + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } - console.log(`[Server] KDS WebSocket running on ws://localhost:${activePort}/kds`); - } + try { + websocketServer.handleUpgrade(request, socket, head, (ws) => { + websocketServer.emit('connection', ws, request); + }); + } catch (error) { + console.error('[Server] KDS WebSocket upgrade failed:', error); + socket.destroy(); + } + }); - // main/index.ts (Electron) also calls this; dev-server and pm2 boot - // through here instead and would otherwise start with module defaults. - try { - initWhatsAppFromDb(); - } catch (error) { - console.error('[Server] WhatsApp startup initialization failed:', error); - } + console.log(`[Server] KDS WebSocket running on ws://localhost:${activePort}/kds`); + } - resolve(); - }; - listeningServer = app.listen(currentPort, '0.0.0.0', onListening); - server = listeningServer; - installHttpShutdownTracking(listeningServer); + // main/index.ts (Electron) also calls this; dev-server and pm2 boot + // through here instead and would otherwise start with module defaults. + try { + initWhatsAppFromDb(); + } catch (error) { + console.error('[Server] WhatsApp startup initialization failed:', error); + } - listeningServer.on('error', (err: NodeJS.ErrnoException) => { - if (stopping) return; - if (err.code === 'EADDRINUSE') { - attempts++; - if (attempts >= 10) { - const errorMsg = `[Server] Failed to bind to any port after 10 attempts starting from ${PORT}`; - console.error(errorMsg); - reject(new Error(errorMsg)); + resolve(); + }; + + const onError = (err: NodeJS.ErrnoException) => { + if (stopping) return; + listeningServer.off('listening', onListening); + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + attempts++; + if (attempts >= 10) { + const errorMsg = `[Server] Failed to bind to any port after 10 attempts starting from ${basePort}`; + console.error(errorMsg); + reject(new Error(errorMsg)); + return; + } + currentPort++; + console.log(`[Server] Port ${attemptedPort} in use (${err.code}), trying ${currentPort}`); + tryListen(); return; } - currentPort++; - console.log(`[Server] Port ${currentPort - 1} in use, trying ${currentPort}`); - listeningServer.listen(currentPort, '0.0.0.0'); - } else { reject(err); - } - }); + }; + + listeningServer.once('listening', onListening); + listeningServer.once('error', onError); + listeningServer.listen(attemptedPort, '0.0.0.0'); + }; + + tryListen(); }); } diff --git a/tests/server-port-collision.test.ts b/tests/server-port-collision.test.ts index 0cb2802d..00784554 100644 --- a/tests/server-port-collision.test.ts +++ b/tests/server-port-collision.test.ts @@ -24,36 +24,236 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { }; async function run(): Promise { - // PORT=0 asks the OS for an ephemeral port. The configured value (0) can - // never equal the actual bound port, so this deterministically exercises the - // same divergence as a collision-selected port: getServerPort() must report - // the real bound port, not the configured one. - process.env.PORT = '0'; - - // Imported after PORT is set so server.ts captures the ephemeral port. const { startServer, stopServer, getServerPort } = await import('../main/server'); + const { startKdsServer, stopKdsServer, getKdsPort } = await import('../main/kds-server'); + const { startServerApp, stopServerApp, getServerAppPort } = await import('../main/server-app'); const { initDatabase, closeDatabase } = await import('../main/db'); + function simulateEaccesOnce() { + const origListen = http.Server.prototype.listen; + let failed = false; + http.Server.prototype.listen = function (this: http.Server, ...args: any[]) { + if (!failed) { + failed = true; + process.nextTick(() => { + const err: NodeJS.ErrnoException = new Error('listen EACCES: permission denied 0.0.0.0'); + err.code = 'EACCES'; + err.syscall = 'listen'; + this.emit('error', err); + }); + return this; + } + return origListen.apply(this, args); + }; + return () => { + http.Server.prototype.listen = origListen; + }; + } + + function simulateEaccesAlways(maxFails = 12) { + const origListen = http.Server.prototype.listen; + let count = 0; + http.Server.prototype.listen = function (this: http.Server, ...args: any[]) { + if (count < maxFails) { + count++; + process.nextTick(() => { + const err: NodeJS.ErrnoException = new Error('listen EACCES: permission denied 0.0.0.0'); + err.code = 'EACCES'; + err.syscall = 'listen'; + this.emit('error', err); + }); + return this; + } + return origListen.apply(this, args); + }; + return () => { + http.Server.prototype.listen = origListen; + }; + } + try { initDatabase(); + + // ── 1. API Server (main/server.ts) ────────────────────────────────── + console.log('[Test] Testing API server...'); + + // Ephemeral port + process.env.PORT = '0'; + await startServer(); + const ephemeralPort = getServerPort(); + assert.equal(typeof ephemeralPort, 'number', 'the active port is a number'); + assert.ok(ephemeralPort > 0, 'getServerPort() reports a real bound port, not the configured 0'); + const ephemStatus = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: ephemeralPort, path: '/api/health' }, (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode ?? 0)); + }); + req.once('error', reject); + }); + assert.equal(ephemStatus, 200, 'health check responds on ephemeral port'); + await stopServer().catch(() => {}); + console.log('✅ API Server ephemeral port passed'); + + // EADDRINUSE collision fallback + const dummyServer1 = http.createServer((_, res) => res.end('occupied')); + const occupiedPort1 = await new Promise((resolve) => { + dummyServer1.listen(0, '0.0.0.0', () => { + const addr = dummyServer1.address(); + resolve(typeof addr === 'object' && addr ? addr.port : 0); + }); + }); + process.env.PORT = String(occupiedPort1); + await startServer(); + const collidedPort1 = getServerPort(); + assert.equal(collidedPort1, occupiedPort1 + 1, 'API Server incremented past occupied port'); + const collidedStatus1 = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: collidedPort1, path: '/api/health' }, (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode ?? 0)); + }); + req.once('error', reject); + }); + assert.equal(collidedStatus1, 200, 'API Server health check responds on EADDRINUSE fallback port'); + dummyServer1.close(); + await stopServer().catch(() => {}); + console.log('✅ API Server EADDRINUSE collision fallback passed'); + + // EACCES fallback + const restoreEacces1 = simulateEaccesOnce(); + const basePort1 = 34500; + process.env.PORT = String(basePort1); await startServer(); + restoreEacces1(); + const eaccesPort1 = getServerPort(); + assert.equal(eaccesPort1, basePort1 + 1, 'API Server incremented past EACCES port'); + const eaccesStatus1 = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: eaccesPort1, path: '/api/health' }, (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode ?? 0)); + }); + req.once('error', reject); + }); + assert.equal(eaccesStatus1, 200, 'API Server health check responds on EACCES fallback port'); + await stopServer().catch(() => {}); + console.log('✅ API Server EACCES fallback passed'); + + // Retry exhaustion + const restoreExhaust1 = simulateEaccesAlways(12); + process.env.PORT = '35000'; + await assert.rejects( + startServer(), + (err: Error) => err.message.includes('Failed to bind to any port after 10 attempts starting from 35000'), + 'API Server fails after 10 failed attempts', + ); + restoreExhaust1(); + await stopServer().catch(() => {}); + console.log('✅ API Server retry exhaustion passed'); + + // ── 2. KDS Server (main/kds-server.ts) ─────────────────────────────── + console.log('[Test] Testing KDS server...'); + + // EADDRINUSE collision fallback + const dummyServer2 = http.createServer((_, res) => res.end('occupied')); + const occupiedPort2 = await new Promise((resolve) => { + dummyServer2.listen(0, '0.0.0.0', () => { + const addr = dummyServer2.address(); + resolve(typeof addr === 'object' && addr ? addr.port : 0); + }); + }); + process.env.KDS_PORT = String(occupiedPort2); + await startKdsServer(); + const collidedPort2 = getKdsPort(); + assert.equal(collidedPort2, occupiedPort2 + 1, 'KDS Server incremented past occupied port'); + dummyServer2.close(); + await stopKdsServer().catch(() => {}); + console.log('✅ KDS Server EADDRINUSE collision fallback passed'); + + // EACCES fallback + const restoreEacces2 = simulateEaccesOnce(); + const basePort2 = 36500; + process.env.KDS_PORT = String(basePort2); + await startKdsServer(); + restoreEacces2(); + const eaccesPort2 = getKdsPort(); + assert.equal(eaccesPort2, basePort2 + 1, 'KDS Server incremented past EACCES port'); + await stopKdsServer().catch(() => {}); + console.log('✅ KDS Server EACCES fallback passed'); + + // Retry exhaustion + const restoreExhaust2 = simulateEaccesAlways(12); + process.env.KDS_PORT = '37000'; + await assert.rejects( + startKdsServer(), + (err: Error) => err.message.includes('Failed to bind to any port after 10 attempts starting from 37000'), + 'KDS Server fails after 10 failed attempts', + ); + restoreExhaust2(); + await stopKdsServer().catch(() => {}); + console.log('✅ KDS Server retry exhaustion passed'); - const activePort = getServerPort(); - assert.equal(typeof activePort, 'number', 'the active port is a number'); - assert.ok(activePort > 0, 'getServerPort() reports a real bound port, not the configured 0'); + // ── 3. Server App (main/server-app.ts) ─────────────────────────────── + console.log('[Test] Testing Server App...'); - const status = await new Promise((resolve, reject) => { - const req = http.get({ host: '127.0.0.1', port: activePort, path: '/api/health' }, (res) => { + // EADDRINUSE collision fallback + const dummyServer3 = http.createServer((_, res) => res.end('occupied')); + const occupiedPort3 = await new Promise((resolve) => { + dummyServer3.listen(0, '0.0.0.0', () => { + const addr = dummyServer3.address(); + resolve(typeof addr === 'object' && addr ? addr.port : 0); + }); + }); + process.env.SERVER_APP_PORT = String(occupiedPort3); + await startServerApp(); + const collidedPort3 = getServerAppPort(); + assert.equal(collidedPort3, occupiedPort3 + 1, 'Server App incremented past occupied port'); + const collidedStatus3 = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: collidedPort3, path: '/api/health' }, (res) => { res.resume(); res.once('end', () => resolve(res.statusCode ?? 0)); }); req.once('error', reject); }); - assert.equal(status, 200, 'health check responds on the port reported by getServerPort()'); + assert.equal(collidedStatus3, 200, 'Server App health check responds on fallback port'); + dummyServer3.close(); + await stopServerApp().catch(() => {}); + console.log('✅ Server App EADDRINUSE collision fallback passed'); + + // EACCES fallback + const restoreEacces3 = simulateEaccesOnce(); + const basePort3 = 38500; + process.env.SERVER_APP_PORT = String(basePort3); + await startServerApp(); + restoreEacces3(); + const eaccesPort3 = getServerAppPort(); + assert.equal(eaccesPort3, basePort3 + 1, 'Server App incremented past EACCES port'); + const eaccesStatus3 = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: eaccesPort3, path: '/api/health' }, (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode ?? 0)); + }); + req.once('error', reject); + }); + assert.equal(eaccesStatus3, 200, 'Server App health check responds on EACCES fallback port'); + await stopServerApp().catch(() => {}); + console.log('✅ Server App EACCES fallback passed'); + + // Retry exhaustion + const restoreExhaust3 = simulateEaccesAlways(12); + process.env.SERVER_APP_PORT = '39000'; + await assert.rejects( + startServerApp(), + (err: Error) => err.message.includes('Failed to bind to any port after 10 attempts starting from 39000'), + 'Server App fails after 10 failed attempts', + ); + restoreExhaust3(); + await stopServerApp().catch(() => {}); + console.log('✅ Server App retry exhaustion passed'); - console.log('✅ Server port-collision tests passed'); + console.log('\n🎉 ALL PORT COLLISION & EACCES FALLBACK TESTS PASSED!'); } finally { await stopServer().catch(() => {}); + await stopKdsServer().catch(() => {}); + await stopServerApp().catch(() => {}); closeDatabase(); } }