From d580d749f4b9205d43e2783bbbdc5d93d9faa089 Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:52:06 +0100 Subject: [PATCH] fix: Free port 5678 when quitting `n8n-node dev` (no-changelog) (#30786) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Claude Opus 4.7 Co-authored-by: Declan Carroll Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Michael Kret --- .../@n8n/node-cli/src/commands/dev/utils.ts | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.ts b/packages/@n8n/node-cli/src/commands/dev/utils.ts index 1e4ffabc494..68c8390a663 100644 --- a/packages/@n8n/node-cli/src/commands/dev/utils.ts +++ b/packages/@n8n/node-cli/src/commands/dev/utils.ts @@ -30,6 +30,7 @@ const CONFIG = { KILL_TIMEOUT_MS: 1000, PROCESS_KILL_DELAY_MS: 100, EXIT_KILL_TIMEOUT_MS: 500, + GROUP_POLL_INTERVAL_MS: 50, }; function calculatePanelHeight(numPanels: number, headerLines: number): number { @@ -336,54 +337,61 @@ function printAllCommandOutputs(outputs: CommandOutput[], headerText?: string): ); } +function isProcessGroupAlive(pgid: number): boolean { + if (process.platform === 'win32') return false; + try { + process.kill(-pgid, 0); + return true; + } catch { + return false; + } +} + +function sendKillSignal(proc: ChildProcess, pid: number, signal: 'SIGTERM' | 'SIGKILL'): void { + try { + if (process.platform === 'win32') { + execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS }); + } else { + process.kill(-pid, signal); + } + } catch { + try { + proc.kill(signal); + } catch { + // Process already gone + } + } +} + async function killProcess(proc: ChildProcess, graceful: boolean): Promise { - if (!proc.pid || proc.exitCode !== null) return; + if (!proc.pid || proc.exitCode !== null || proc.signalCode !== null) return; const pid = proc.pid; + const isWindows = process.platform === 'win32'; + const hasExited = (): boolean => proc.exitCode !== null || proc.signalCode !== null; - return await new Promise((resolve) => { - let timeoutId: NodeJS.Timeout | null = null; + sendKillSignal(proc, pid, graceful ? 'SIGTERM' : 'SIGKILL'); - if (graceful) { - timeoutId = setTimeout(() => { - try { - if (process.platform === 'win32') { - execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS }); - } else { - process.kill(-pid, 'SIGKILL'); - } - } catch { - // Ignore errors during force kill - } - resolve(); - }, CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT); - } + if (!graceful) { + await sleep(CONFIG.PROCESS_KILL_DELAY_MS); + return; + } - proc.once('exit', () => { - if (timeoutId) clearTimeout(timeoutId); - resolve(); - }); + // Wait for the whole process group to drain rather than only the direct + // child. The direct child (shell wrapper or `npx`) typically exits much + // faster than the n8n server it spawns, which needs time to release its + // listening port. Exiting before the descendants finish would leak the + // port until the user manually killed the process. + const deadline = Date.now() + CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT; + while (Date.now() < deadline) { + const groupDrained = isWindows || !isProcessGroupAlive(pid); + if (hasExited() && groupDrained) return; + await sleep(CONFIG.GROUP_POLL_INTERVAL_MS); + } - try { - if (process.platform === 'win32') { - execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS }); - } else { - process.kill(-pid, graceful ? 'SIGTERM' : 'SIGKILL'); - } - } catch { - try { - proc.kill(graceful ? 'SIGTERM' : 'SIGKILL'); - } catch { - if (timeoutId) clearTimeout(timeoutId); - resolve(); - } - } - - if (!graceful) { - if (timeoutId) clearTimeout(timeoutId); - setTimeout(resolve, CONFIG.PROCESS_KILL_DELAY_MS); - } - }); + // Graceful window elapsed — force-kill anything still alive in the group. + sendKillSignal(proc, pid, 'SIGKILL'); + await sleep(CONFIG.PROCESS_KILL_DELAY_MS); } export function runCommands(config: CommandsConfig): void { @@ -445,18 +453,20 @@ export function runCommands(config: CommandsConfig): void { process.on('SIGTERM', handleSignal); process.on('exit', () => { - if (!cleanupPerformed && childProcesses.length > 0) { - for (const proc of childProcesses) { - if (!proc.pid) continue; - try { - if (process.platform === 'win32') { - execSync(`taskkill /PID ${proc.pid} /T /F`, { timeout: CONFIG.EXIT_KILL_TIMEOUT_MS }); - } else { - process.kill(-proc.pid, 'SIGKILL'); - } - } catch { - // Ignore errors during exit cleanup + // Always fire a final SIGKILL to each spawned process group. If the + // graceful cleanup already drained the group this is a no-op; if it + // didn't (e.g. unexpected exit, race), this prevents the n8n server + // or other descendants from outliving the CLI and holding their port. + for (const proc of childProcesses) { + if (!proc.pid) continue; + try { + if (process.platform === 'win32') { + execSync(`taskkill /PID ${proc.pid} /T /F`, { timeout: CONFIG.EXIT_KILL_TIMEOUT_MS }); + } else { + process.kill(-proc.pid, 'SIGKILL'); } + } catch { + // Ignore errors during exit cleanup } } });