Node.js热重载残留僵尸进程占用端口问题求助
问题解决:Node.js热重载后旧进程残留占用端口
核心问题定位
你遇到的问题确实是Bree的Worker Threads未彻底终止导致旧进程无法退出,进而占用端口。bree.stop()只是触发停止指令,但内部Worker可能还在执行异步任务,你的Promise.race加1秒超时会提前结束等待,导致Worker残留;另外手动调用process.exit(0)可能跳过Node.js的自然退出流程,残留的句柄会让进程保持存活。
针对性解决方案
1. 确保Bree工作线程彻底终止
替换原来的Bree停止逻辑,不要用超时跳过等待,而是等待所有Worker真正退出:
// 替换shutdown函数中Bree相关的代码 await bree.stop(); // 等待所有Worker线程完全终止 await Promise.all( Array.from(bree.workers.values()).map(worker => new Promise((resolve) => { // 如果Worker已经断开,直接resolve if (worker.exitedAfterDisconnect) return resolve(true); // 监听exit和error事件,确保Worker终止 worker.once('exit', resolve); worker.once('error', resolve); }) ) ); consola.success('Bree workers fully terminated');
同时,给Bree初始化时加上workerExit事件监听,排查是否有Worker异常退出:
bree.on('workerExit', (worker, workerId) => { consola.debug(`Worker ${workerId} exited with code ${worker.exitCode}`); });
2. 修复进程退出逻辑
- 移除手动的
process.exit(0),Node.js会在所有事件循环任务完成后自动退出,手动exit可能跳过Worker的清理流程。 - 添加防重复触发的标记,避免信号多次触发shutdown:
let isShuttingDown = false; async function shutdown(signal: string) { if (isShuttingDown) return; isShuttingDown = true; // 保留超时逻辑但延长时间,给Worker足够清理时间,不要unref const timeout = setTimeout(() => { consola.error('Timeout dépassé, force exit'); process.exit(1); }, 5000); try { // ... 原有资源清理逻辑(销毁连接、关闭服务器、Redis、SQLite) // 等待Bree Worker完全终止(上面修改后的代码) consola.success('Shutdown complete'); clearTimeout(timeout); // 清理超时器,让进程自然退出 } catch (err) { consola.error('Erreur pendant shutdown:', err); clearTimeout(timeout); process.exit(1); } }
3. 排查残留活跃句柄
在shutdown的最后(退出前),打印所有活跃句柄和请求,找出残留资源:
console.log('=== Active Handles ==='); process._getActiveHandles().forEach(handle => { console.log(handle.constructor.name, handle); }); console.log('=== Active Requests ==='); process._getActiveRequests().forEach(req => { console.log(req.constructor.name, req); });
如果输出里还有Worker实例,说明还有线程没终止,需要进一步排查Worker内部的代码是否有未处理的异步任务(比如定时器、未关闭的数据库连接)。
4. 调整热重载启动时机
方案A:给热重载工具加延迟
对于nodemon,修改nodemon.json添加启动延迟:
{ "delay": 3000, "watch": ["src/**/*.ts"], "exec": "node --import tsx app.ts" }
对于原生node --watch,可以写一个简单的启动脚本,等待端口释放后再启动新进程:
// start.js const net = require('net'); const { spawn } = require('child_process'); function waitForPortFree(port) { return new Promise(resolve => { const check = () => { const socket = new net.Socket(); socket.on('connect', () => { socket.destroy(); setTimeout(check, 500); }); socket.on('error', () => { resolve(); }); socket.connect(port, 'localhost'); }; check(); }); } async function startServer() { await waitForPortFree(3001); spawn('node', ['--watch', '--import', 'tsx', 'app.ts'], { stdio: 'inherit' }); } startServer();
启动命令改为node start.js,脚本会自动等待3001端口释放后再启动新进程。
方案B:用IPC通信通知热重载工具
让旧进程在shutdown完成后发送信号给热重载工具,确认可以启动新进程。比如自定义nodemon的exit钩子,但实现起来稍复杂,适合需要精确控制的场景。
额外注意事项
- 检查Worker线程内部的代码:如果Worker里有未处理的Promise、定时器或者持久化连接,即使调用
bree.stop()也无法终止,需要在Worker内部监听message事件,收到停止信号时主动清理资源并退出。 - 测试时不要快速连续保存文件:频繁触发热重载会增加进程残留的概率,建议每次保存后等待旧进程完全退出再进行下一次保存。
内容的提问来源于stack exchange,提问作者ckHunter
相关产品推荐
相关产品推荐

