fix(web): boot 前回收 detached 残留 server,修 EADDRINUSE 起不来 (#197)

- restart-web.js 把后台 server 的 pid/url 记到 \/dsh-purge/web-server-<port>.json
- killOldServer 增加按端口兜底:清掉旧版本留下、没有 pid 记录的孤儿
- 新增 lib/bin-preflight.js:把回收钩子注入宿主 bin.js 顶部,早于 Cordis 激活与 webserver 绑端口
- 重启时打印『后台运行、关窗不会停』,避免把提示符返回误判成程序已退出
- 新增 test/web-reclaim.test.js(7 例),全量 node --test 91/91 通过
This commit is contained in:
iigzx committed 2026-10-31 21:30:17 +08:00
1 parent 9716ca3e87
commit 8d237baf9b
7 files changed
+544 -10

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
// #197:把「启动前回收残留 web server」的钩子挂到宿主 bin.js 顶部。
// 必须在 Cordis 激活之前跑:webserver 插件绑端口失败在 0.2.x 是 fatal,等插件层再处理就晚了。
// 注入形态与 hide-console 一致:宿主 lib 下放一个 sidecar + bin.js 顶部加一行 import。
// 与 hide-console 的区别:这里跨平台生效(detached 重启在 macOS/Linux 同样会留下孤儿)。
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isAsarSealedPath } from "./desktop.js";
import { looksLikeSourceCliRoot, resolveDshBinRoot } from "./hide-console.js";
const MARK = "dsh-purge-web-preflight";
const IMPORT_LINE = `import "./${MARK}.js"; // [dsh-purge] web preflight`;
const ENTRY = fileURLToPath(new URL("./web-preflight-entry.js", import.meta.url));
export function installBinPreflight(aiBase) {
const root = resolveDshBinRoot(aiBase);
if (!root) return "missing_bin";
// 源码部署的 apps/cli/lib 是 tsdown 产物:只准读、不准改(#41)。
if (looksLikeSourceCliRoot(root)) return "skipped_source_cli";
const binJs = path.join(root, "lib", "bin.js");
const side = path.join(root, "lib", `${MARK}.js`);
if (isAsarSealedPath(root) || isAsarSealedPath(binJs)) return "sealed_asar";
let source;
try {
source = fs.readFileSync(ENTRY, "utf8");
fs.writeFileSync(side, source, "utf8");
} catch (e) {
return `error:${e && e.message ? e.message : e}`;
}
let text;
try {
text = fs.readFileSync(binJs, "utf8");
} catch (e) {
return `error:${e && e.message ? e.message : e}`;
}
if (text.includes(IMPORT_LINE)) return "already_injected";
text = text.replace(/^import "\.\/dsh-purge-web-preflight\.js";.*\r?\n/gm, "");
if (/^#!/.test(text)) {
const nl = text.indexOf("\n");
text =
nl >= 0
? text.slice(0, nl + 1) + `${IMPORT_LINE}\n` + text.slice(nl + 1)
: `${text}\n${IMPORT_LINE}\n`;
} else {
text = `${IMPORT_LINE}\n${text}`;
}
try {
fs.writeFileSync(binJs, text, "utf8");
} catch (e) {
return `error:${e && e.message ? e.message : e}`;
}
return "injected";
}
export function revertBinPreflight(aiBase) {
const root = resolveDshBinRoot(aiBase);
if (!root) return "missing_bin";
const binJs = path.join(root, "lib", "bin.js");
const side = path.join(root, "lib", `${MARK}.js`);
let changed = false;
if (!looksLikeSourceCliRoot(root)) {
try {
const text = fs.readFileSync(binJs, "utf8");
const next = text.replace(/^import "\.\/dsh-purge-web-preflight\.js";.*\r?\n/gm, "");
if (next !== text) {
fs.writeFileSync(binJs, next, "utf8");
changed = true;
}
} catch (e) {
return `error:${e && e.message ? e.message : e}`;
}
}
try {
if (fs.existsSync(side)) {
fs.unlinkSync(side);
changed = true;
}
} catch {
/* 重启后再删 */
}
return changed ? "reverted" : "absent";
}
+20 -3
View File
@@ -11,6 +11,7 @@ import {
resolveDshBinRoot,
revertHideConsoleFromBin,
} from "./hide-console.js";
import { installBinPreflight, revertBinPreflight } from "./bin-preflight.js";
import * as desktop from "./desktop.js";
import {
asarArchiveIsFile,
@@ -400,7 +401,14 @@ export function ensureHiddenConsole(aiBase = findAiBase()) {
} catch (e) {
entry = `error:${e && e.message ? e.message : e}`;
}
return { live, entry, entryRoot };
// #197:web 重启后的孤儿 server 要在 Cordis 激活前回收,钩子必须早于 webserver 绑端口。
let preflight = "unknown";
try {
preflight = installBinPreflight(entryRoot || aiBase);
} catch (e) {
preflight = `error:${e && e.message ? e.message : e}`;
}
return { live, entry, preflight, entryRoot };
}
export function silenceCmdFlash(aiBase = findAiBase()) {
@@ -429,6 +437,9 @@ export function silenceCmdFlash(aiBase = findAiBase()) {
liangshenPrompt === "already" ||
liangshenPrompt === "patched" ||
liangshenPrompt === "pattern_miss";
const preflightOk =
["injected", "already_injected", "skipped_source_cli", "missing_bin", "sealed_asar"].includes(hide.preflight) ||
(typeof hide.preflight === "string" && hide.preflight.startsWith("error:"));
return {
...hide,
doctor,
@@ -440,7 +451,7 @@ export function silenceCmdFlash(aiBase = findAiBase()) {
officialPhase1,
liangshenPrompt,
mnemon,
ok: hideOk && liangshenOk,
ok: hideOk && liangshenOk && preflightOk,
};
}
@@ -582,7 +593,13 @@ export function revertCmdFlash(aiBase = findAiBase(), dshHome = findDshHome()) {
if (next.includes(importReplacement)) next = next.replace(importReplacement, importNeedle);
return next;
});
return { hide, subprocess, customBash, phase1, doctorSpawn, market };
let preflight = "absent";
try {
preflight = revertBinPreflight(aiBase);
} catch (e) {
preflight = `error:${e && e.message ? e.message : e}`;
}
return { hide, preflight, subprocess, customBash, phase1, doctorSpawn, market };
}
export function silenceSubprocessLocalFlash(dshHome = findDshHome()) {
+33 -7
View File
@@ -1,6 +1,14 @@
import net from "node:net";
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { envForDetachedSpawn } from "./core.js";
import {
cmdlineOf,
dshHomeDir,
killPid,
listenerPids,
looksLikeDshWeb,
writeRecord,
} from "./web-reclaim.js";
const encoded = process.env.DSH_PURGE_RESTART;
if (!encoded) process.exit(1);
@@ -25,14 +33,18 @@ function portFree() {
});
}
/**
* 杀掉「发起重启的那个进程」;再按端口兜底一次,清掉旧版本留下、没有 pid 记录的孤儿
* (#197:只按 info.pid 杀时,上一轮 detached 起来的 server 永远没人管)。
*/
function killOldServer() {
const pid = Number(info.pid);
if (!pid || pid === process.pid) return;
if (process.platform === "win32") {
spawnSync("taskkill", ["/PID", String(pid), "/F"], { windowsHide: true, stdio: "ignore" });
return;
if (pid && pid !== process.pid) killPid(pid);
for (const other of listenerPids(port)) {
if (other === process.pid || other === pid) continue;
if (!looksLikeDshWeb(cmdlineOf(other))) continue;
killPid(other);
}
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
}
function openBrowser(url) {
@@ -77,6 +89,15 @@ const child = spawn(info.execPath, info.argv, {
shell: false,
});
child.on("error", () => process.exit(1));
// #197:记下这个后台 server 的 pid。下次 `dsh web` 启动时,注入到宿主 bin.js 的
// preflight 会先把它收掉,不会再撞 EADDRINUSE。
child.on("spawn", () => {
try {
writeRecord(dshHomeDir(), port, child.pid, { host, url: "" });
} catch {
/* 记录失败不影响重启 */
}
});
const wantBrowser = info.openBrowser !== false;
const takeUrl = (chunk) => {
const text = String(chunk || "");
@@ -84,8 +105,13 @@ const takeUrl = (chunk) => {
const match = text.match(/dsh web:\s+(https?:\/\/\S+)/);
if (!match) return;
opened = true;
if (!wantBrowser) return;
const url = match[1].replace(/[),.;]+$/, "");
try {
writeRecord(dshHomeDir(), port, child.pid, { host, url });
} catch {
/* ignore */
}
if (!wantBrowser) return;
openBrowser(url);
};
child.stdout?.on("data", takeUrl);
+124
View File
@@ -0,0 +1,124 @@
// [dsh-purge] web preflight — 由 lib/bin-preflight.js 复制到宿主 lib 并 import 到 bin.js 顶部。
//
// 目的:dsh-purge 的「应用 → 自动重启」用 detached 子进程接管 server,父进程随即退出,
// 关窗/关终端都杀不到那个子进程;下一次 `dsh web` 会在 webserver 插件绑端口时撞
// EADDRINUSE,而 0.2.x 把 required 插件激活失败当 fatal,宿主直接起不来(issue #197)。
//
// 这里在 Cordis 激活之前做一次回收:先查 $DSH_HOME/dsh-purge/web-server-<port>.json,
// 记录失效时再按端口兜底;只杀命令行命中 `@deepseek-ai/dsh/lib/bin.js … web` 的进程。
// 只处理 `dsh web`,其它子命令零开销。
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const DSH_BIN = /@deepseek-ai[\\/]dsh[\\/]lib[\\/]bin\.js/;
const WEB_ARG = /(^|[\s"'])web([\s"']|$)/;
function run(file, args) {
try {
const r = spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 8000 });
return r && r.status === 0 ? String(r.stdout || "") : "";
} catch {
return "";
}
}
function cmdlineOf(pid) {
if (process.platform === "win32") {
return run("powershell.exe", [
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue).CommandLine`,
]).trim();
}
return run("ps", ["-p", String(pid), "-o", "command="]).trim();
}
function isDshWeb(cmd) {
return DSH_BIN.test(cmd) && WEB_ARG.test(cmd);
}
function alive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function kill(pid) {
if (!pid || pid === process.pid) return false;
if (process.platform === "win32") {
const r = spawnSync("taskkill", ["/PID", String(pid), "/F"], { windowsHide: true, stdio: "ignore" });
return !r.error && r.status === 0;
}
try {
process.kill(pid, "SIGKILL");
return true;
} catch {
return false;
}
}
function listeningPids(port) {
if (process.platform === "win32") {
const out = run("netstat", ["-ano"]);
const pids = [];
for (const line of out.split(/\r?\n/)) {
const m = line.match(/^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i);
if (m && Number(m[2]) === port) pids.push(Number(m[3]));
}
return [...new Set(pids)];
}
const out = run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
return [...new Set(out.split(/\s+/).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
}
function main() {
if (!process.argv.slice(2).some((a) => a === "web")) return;
const i = process.argv.indexOf("--port");
const port = Number(i > -1 ? process.argv[i + 1] : process.env.PORT) || 3080;
const home = String(process.env.DSH_HOME || "").trim() || path.join(os.homedir(), ".dsh");
const record = path.join(home, "dsh-purge", `web-server-${port}.json`);
try {
let killed = 0;
let rec = null;
try {
rec = JSON.parse(fs.readFileSync(record, "utf8"));
} catch {
rec = null;
}
if (rec && rec.pid && alive(rec.pid)) {
const cmd = cmdlineOf(rec.pid);
if (!cmd || isDshWeb(cmd)) {
if (kill(rec.pid)) killed += 1;
}
}
if (rec) {
try {
fs.unlinkSync(record);
} catch {
/* ignore */
}
}
if (!killed) {
for (const pid of listeningPids(port)) {
if (pid === process.pid) continue;
if (!isDshWeb(cmdlineOf(pid))) continue;
if (kill(pid)) killed += 1;
}
}
if (killed) {
process.stderr.write(`[dsh-purge] reclaimed stale dsh web on :${port}, retrying boot\n`);
}
} catch {
/* 回收失败不阻断启动 */
}
}
main();
+188
View File
@@ -0,0 +1,188 @@
// #197:Web「应用 → 自动重启」用 detached 子进程接管 server,父进程 250ms 后退出,
// 关窗/关终端都杀不到它;下一次 `dsh web` 撞 EADDRINUSE,0.2.x 把 required 插件
// 激活失败当 fatal,宿主直接起不来。
//
// 这个模块提供三件事,供 restart-web.js(记录 pid)和注入到宿主 bin.js 的启动前
// 回收脚本(清理残留)共用:
// 1. pid 记录文件 $DSH_HOME/dsh-purge/web-server-<port>.json
// 2. 按端口反查监听 pid(win32: netstat;darwin/linux: lsof)
// 3. 判定某个 pid 是不是 `dsh web`,只杀自己人
//
// 本模块刻意不 import core.js(core 会 import 本模块的方向,避免循环依赖)。
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
export const RECORD_DIR = "dsh-purge";
export const RECORD_PREFIX = "web-server-";
/** 宿主入口的稳定特征:@deepseek-ai/dsh/lib/bin.js */
export const DSH_BIN_PATTERN = /@deepseek-ai[\\/]dsh[\\/]lib[\\/]bin\.js/;
export function dshHomeDir(env = process.env) {
const home = typeof env?.DSH_HOME === "string" ? env.DSH_HOME.trim() : "";
return home || path.join(os.homedir(), ".dsh");
}
export function recordPath(dshHome, port) {
return path.join(dshHome, RECORD_DIR, `${RECORD_PREFIX}${Number(port) || 3080}.json`);
}
export function writeRecord(dshHome, port, pid, extra = {}) {
const fp = recordPath(dshHome, port);
try {
fs.mkdirSync(path.dirname(fp), { recursive: true });
const payload = { pid: Number(pid) || 0, port: Number(port) || 3080, ...extra, at: new Date().toISOString() };
fs.writeFileSync(fp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return fp;
} catch {
return "";
}
}
export function readRecord(dshHome, port) {
try {
const raw = JSON.parse(fs.readFileSync(recordPath(dshHome, port), "utf8"));
if (!raw || typeof raw !== "object") return null;
const pid = Number(raw.pid) || 0;
if (!pid) return null;
return { pid, port: Number(raw.port) || Number(port) || 3080, at: String(raw.at || "") };
} catch {
return null;
}
}
export function clearRecord(dshHome, port) {
try {
fs.unlinkSync(recordPath(dshHome, port));
return true;
} catch {
return false;
}
}
/**
* 命令行长什么样才算「我们自己起的 dsh web」。
* 只认 bin.js 入口 + 带 web 子命令,避免误杀同机其它 node 进程。
*/
export function looksLikeDshWeb(cmdline) {
const text = String(cmdline || "");
if (!text || !DSH_BIN_PATTERN.test(text)) return false;
return /(^|[\s"'])web([\s"']|$)/.test(text) || /(^|[\s"'])web\s+--/.test(text);
}
/** win32 netstat -ano 的 LISTENING 行 → [{ address, port, pid }] */
export function parseNetstatListeners(output) {
const out = [];
for (const line of String(output || "").split(/\r?\n/)) {
const m = line.match(/^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i);
if (!m) continue;
out.push({ address: m[1], port: Number(m[2]), pid: Number(m[3]) });
}
return out;
}
/** lsof -nP -iTCP:PORT -sTCP:LISTEN -t 的输出 → pid 数组 */
export function parseLsofPids(output) {
return String(output || "")
.split(/\s+/)
.map((x) => Number(x))
.filter((n) => Number.isInteger(n) && n > 0);
}
function run(file, args, options = {}) {
try {
const r = spawnSync(file, args, {
encoding: "utf8",
windowsHide: true,
timeout: 8000,
...options,
});
if (r.error || r.status !== 0) return "";
return String(r.stdout || "");
} catch {
return "";
}
}
/** 当前监听某端口的 pid(跨平台) */
export function listenerPids(port) {
const p = Number(port) || 3080;
if (process.platform === "win32") {
const rows = parseNetstatListeners(run("netstat", ["-ano"]));
return [...new Set(rows.filter((r) => r.port === p).map((r) => r.pid))];
}
return parseLsofPids(run("lsof", ["-nP", `-iTCP:${p}`, "-sTCP:LISTEN", "-t"]));
}
/** 取某个 pid 的完整命令行(拿不到就返回空串) */
export function cmdlineOf(pid) {
const n = Number(pid) || 0;
if (!n) return "";
if (process.platform === "win32") {
const out = run("powershell.exe", [
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId=${n}" -ErrorAction SilentlyContinue).CommandLine`,
]);
return out.trim();
}
return run("ps", ["-p", String(n), "-o", "command="]).trim();
}
export function pidAlive(pid) {
const n = Number(pid) || 0;
if (!n) return false;
try {
process.kill(n, 0);
return true;
} catch {
return false;
}
}
export function killPid(pid) {
const n = Number(pid) || 0;
if (!n || n === process.pid) return false;
if (process.platform === "win32") {
const r = spawnSync("taskkill", ["/PID", String(n), "/F"], { windowsHide: true, stdio: "ignore" });
return !r.error && r.status === 0;
}
try {
process.kill(n, "SIGKILL");
return true;
} catch {
return false;
}
}
/**
* 启动前回收:先按记录杀,记录缺失/失效时再按端口兜底。
* @returns {{ killed: number[], source: "record" | "port" | "" }}
*/
export function reclaimStaleWebServer({ port, dshHome, logger } = {}) {
const p = Number(port) || 3080;
const home = dshHome || dshHomeDir();
const log = typeof logger === "function" ? logger : () => {};
const killed = [];
const rec = readRecord(home, p);
if (rec && pidAlive(rec.pid)) {
const cmd = cmdlineOf(rec.pid);
// 拿不到命令行时按记录处理(pid 可能被系统复用,能拿到就必须校验)
if (!cmd || looksLikeDshWeb(cmd)) {
if (killPid(rec.pid)) killed.push(rec.pid);
}
}
if (rec) clearRecord(home, p);
if (killed.length) return { killed, source: "record" };
for (const pid of listenerPids(p)) {
if (pid === process.pid) continue;
const cmd = cmdlineOf(pid);
if (!looksLikeDshWeb(cmd)) continue;
if (killPid(pid)) killed.push(pid);
}
if (killed.length) log(`[dsh-purge] reclaimed stale web server on :${p} (pid ${killed.join(",")})`);
return { killed, source: killed.length ? "port" : "" };
}
+10
View File
@@ -77,6 +77,16 @@ function spawnRestartHelper(helper, payload, exitAfter = 250) {
export function scheduleRestart(request, _ctx, options = {}) {
const openBrowser = options.openBrowser ?? (request != null);
const helper = fileURLToPath(new URL("./restart-web.js", import.meta.url));
// #197:前台进程 250ms 后就会退出,终端回到提示符,但 server 在后台跑。
// 不把这句话打出来,用户会以为程序已经关掉,然后立刻再开一个 → EADDRINUSE。
try {
console.log(
"[dsh-purge] restarting dsh web in the background; closing this window will NOT stop it. " +
"The next `dsh web` reclaims it automatically.",
);
} catch {
/* ignore */
}
spawnRestartHelper(helper, {
kind: "web",
execPath: process.execPath,
+84
View File
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
clearRecord,
dshHomeDir,
looksLikeDshWeb,
parseLsofPids,
parseNetstatListeners,
readRecord,
recordPath,
writeRecord,
} from "../lib/web-reclaim.js";
test("recordPath 落在 $DSH_HOME/dsh-purge 下,按端口区分", () => {
const home = path.join(os.tmpdir(), "dsh-home-x");
assert.equal(recordPath(home, 3080), path.join(home, "dsh-purge", "web-server-3080.json"));
assert.equal(recordPath(home, 4000), path.join(home, "dsh-purge", "web-server-4000.json"));
});
test("write/read/clear 记录往返", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-purge-rec-"));
try {
writeRecord(home, 3080, 4321, { url: "http://127.0.0.1:3080/?token=x" });
const rec = readRecord(home, 3080);
assert.equal(rec.pid, 4321);
assert.equal(rec.port, 3080);
assert.ok(rec.at);
assert.ok(clearRecord(home, 3080));
assert.equal(readRecord(home, 3080), null);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test("readRecord 对坏文件返回 null 而不抛", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-purge-bad-"));
try {
fs.mkdirSync(path.join(home, "dsh-purge"), { recursive: true });
fs.writeFileSync(path.join(home, "dsh-purge", "web-server-3080.json"), "{not json", "utf8");
assert.equal(readRecord(home, 3080), null);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test("dshHomeDir 优先 DSH_HOME,其次 ~/.dsh", () => {
assert.equal(dshHomeDir({ DSH_HOME: " D:\\dsh-home " }), "D:\\dsh-home");
assert.equal(dshHomeDir({}), path.join(os.homedir(), ".dsh"));
});
test("looksLikeDshWeb 只认 dsh bin.js + web 子命令", () => {
const win =
'"node" "C:\\Users\\X\\AppData\\Roaming\\npm\\\\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js" web';
const posix = "/usr/bin/node /usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js web --port 3080";
assert.equal(looksLikeDshWeb(win), true);
assert.equal(looksLikeDshWeb(posix), true);
// 同机其它 node 进程不能被误杀
assert.equal(looksLikeDshWeb('"node" "C:\\app\\server.js" web'), false);
assert.equal(looksLikeDshWeb('"node" "…\\@deepseek-ai\\dsh\\lib\\bin.js" desktop'), false);
assert.equal(looksLikeDshWeb(""), false);
assert.equal(looksLikeDshWeb(undefined), false);
});
test("parseNetstatListeners 抽出 LISTENING 行", () => {
const out = [
" TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 28276",
" TCP 127.0.0.1:3080 127.0.0.1:50511 TIME_WAIT 0",
" TCP [::1]:4000 [::]:0 LISTENING 999",
].join("\r\n");
const rows = parseNetstatListeners(out);
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], { address: "127.0.0.1", port: 3080, pid: 28276 });
assert.equal(rows[1].pid, 999);
});
test("parseLsofPids 处理多行/空输出", () => {
assert.deepEqual(parseLsofPids("1234\n5678\n"), [1234, 5678]);
assert.deepEqual(parseLsofPids(""), []);
assert.deepEqual(parseLsofPids(" \n"), []);
});