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:
1 parent
9716ca3e87
commit
8d237baf9b
7 files changed
+544
-10
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"), []);
|
||||
});
|
||||
Reference in new issue
Block a user