| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- "use strict";
- /**
- * Supervisor for a local `dsh web` service.
- *
- * - attach: if a healthy dsh is already listening on the default port (its
- * HTML shell carries `__DSH_BOOT__`), use it and own nothing.
- * - spawn: otherwise start `dsh web --port <n>` as a supervised child; a busy
- * default port falls back to `--port 0` (the actual port is parsed from the
- * child's "dsh web: http://127.0.0.1:<port>" line, with an lsof/netstat
- * fallback).
- * - crash: restart with exponential backoff (supervised while stopping or
- * upgrading, and never for an instance we did not spawn).
- * - stop: SIGTERM first — dsh shuts down gracefully — then SIGKILL on timeout
- * (Windows: taskkill /T, which has no graceful-signal equivalent).
- */
- const { spawn, execFile, execFileSync } = require("node:child_process");
- const http = require("node:http");
- const fs = require("node:fs");
- const path = require("node:path");
- const readline = require("node:readline");
- const { DSH_BIN, resolveDsh, childEnv, IS_WIN } = require("./env");
- const DEFAULT_PORT = Number(process.env.DSH_DESKTOP_PORT || 3080);
- const READY_TIMEOUT_MS = 120_000;
- const STOP_GRACE_MS = 8_000;
- const BACKOFF_MS = [1_000, 5_000, 30_000, 60_000];
- /** Small GET with timeout; never rejects. */
- function httpGet(url, timeoutMs = 2000) {
- return new Promise((resolve) => {
- let settled = false;
- const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
- const req = http.get(url, (res) => {
- let body = "";
- res.on("data", (chunk) => {
- body += chunk;
- if (body.length >= 16384) { req.destroy(); finish({ ok: true, status: res.statusCode, body }); }
- });
- res.on("end", () => finish({ ok: true, status: res.statusCode, body }));
- res.on("error", () => finish({ ok: true, status: res.statusCode, body }));
- });
- req.on("error", () => finish({ ok: false }));
- req.setTimeout(timeoutMs, () => { req.destroy(); finish({ ok: false }); });
- });
- }
- /** A healthy dsh web UI answers 2xx and its HTML shell carries the boot marker. */
- async function probeDsh(port) {
- const res = await httpGet(`http://127.0.0.1:${port}/`);
- return Boolean(res.ok && res.status && res.status < 500 && res.body.includes("__DSH_BOOT__"));
- }
- class Service {
- constructor({ userDataDir, onState, onLog }) {
- this.userDataDir = userDataDir;
- this.onState = onState || (() => {});
- this.onLog = onLog || (() => {});
- this.child = null;
- this.port = null;
- this.spawned = false; // did we start this dsh?
- this.state = "stopped"; // stopped | starting | running | stopping | crashed
- this.detail = "";
- this.upgrading = false; // suppress crash-restart while the updater works
- this.stopping = false;
- this.backoffIndex = 0;
- this.logLines = [];
- this.logFile = path.join(userDataDir, "dsh-web.log");
- this._ver = null;
- this._verAt = 0;
- }
- get url() { return this.port ? `http://127.0.0.1:${this.port}` : null; }
- get running() { return this.state === "running"; }
- setState(state, detail = "") {
- this.state = state;
- this.detail = detail;
- this.onState(this);
- }
- appendLog(line) {
- this.logLines.push(line);
- if (this.logLines.length > 500) this.logLines.shift();
- this.onLog(line);
- try { fs.appendFileSync(this.logFile, line + "\n"); } catch { /* disk issues must not kill the shell */ }
- }
- ensureLogFile() {
- try { fs.closeSync(fs.openSync(this.logFile, "a")); } catch { /* ignore */ }
- return this.logFile;
- }
- /** Current @deepseek-ai/dsh version, read from its package.json (cached). */
- version() {
- const now = Date.now();
- if (this._ver && now - this._verAt < 10_000) return this._ver;
- let version = null;
- try {
- let dir = path.dirname(fs.realpathSync(DSH_BIN));
- for (let i = 0; i < 8 && dir !== path.dirname(dir); i++) {
- const manifest = path.join(dir, "package.json");
- if (fs.existsSync(manifest)) {
- const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
- if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
- }
- // Windows npm layout: <prefix>/dsh.cmd + <prefix>/node_modules/@deepseek-ai/dsh
- const nm = path.join(dir, "node_modules", "@deepseek-ai", "dsh", "package.json");
- if (fs.existsSync(nm)) {
- const pkg = JSON.parse(fs.readFileSync(nm, "utf8"));
- if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
- }
- dir = path.dirname(dir);
- }
- } catch { /* fall through to the CLI */ }
- if (!version) {
- try {
- version = execFileSync(DSH_BIN, ["-V"], {
- encoding: "utf8",
- shell: IS_WIN, // .cmd shims need cmd.exe
- }).trim();
- } catch { /* unknown */ }
- }
- this._ver = version;
- this._verAt = now;
- return version;
- }
- /** Attach to a healthy instance, else spawn (fixed port first, then a free one).
- * ENOENT retries cover the `npm install -g` window: while npm rewrites the
- * bin symlink the spawn target briefly does not exist. */
- async ensure() {
- if (process.env.DSH_DESKTOP_NO_ATTACH !== "1" && await probeDsh(DEFAULT_PORT)) {
- this.port = DEFAULT_PORT;
- this.spawned = false;
- this.setState("running", "attached");
- this.appendLog(`[desktop] attached to an existing dsh at ${this.url}`);
- return true;
- }
- for (let attempt = 0; attempt < 6; attempt++) {
- const wanted = attempt === 0 ? DEFAULT_PORT : 0;
- if (await this.start(wanted)) return true;
- // ENOENT (bin link mid-rewrite) is worth waiting out; other failures
- // stop after the usual port fallback.
- if (!this.lastSpawnEnoent && attempt >= 1) return false;
- this.lastSpawnEnoent = false;
- this.appendLog(`[desktop] spawn target missing; retry ${attempt + 1}/5 in 3s`);
- await new Promise((r) => setTimeout(r, 3000));
- }
- return false;
- }
- start(wanted = DEFAULT_PORT) {
- if (this.child || this.running) return Promise.resolve(true);
- this.stopping = false;
- this.setState("starting", wanted === 0 ? "spawn · free port" : `spawn · :${wanted}`);
- return new Promise((resolve) => {
- let settled = false;
- let portHint = null;
- const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };
- const bin = resolveDsh(); // re-resolved per spawn: npm upgrades replace the link
- // On Windows, dsh is a .cmd shim — spawn via cmd.exe (shell:true). We
- // pass the bare name so cmd resolves it through the augmented PATH,
- // sidestepping spaces in absolute install paths.
- const spawnBin = IS_WIN ? "dsh" : bin;
- let child;
- try {
- child = spawn(spawnBin, ["web", "--port", String(wanted)], {
- stdio: ["ignore", "pipe", "pipe"],
- env: childEnv(),
- shell: IS_WIN ? true : false,
- windowsHide: true,
- });
- } catch (err) {
- this.lastSpawnEnoent = err.code === "ENOENT";
- this.setState("crashed", err.message);
- done(false);
- return;
- }
- this.child = child;
- this.spawned = true;
- this.appendLog(`[desktop] spawn: ${bin} web --port ${wanted} (pid ${child.pid})`);
- for (const stream of [child.stdout, child.stderr]) {
- readline.createInterface({ input: stream }).on("line", (line) => {
- if (line) this.appendLog(line);
- const m = line.match(/http:\/\/127\.0\.0\.1:(\d+)/);
- if (m) portHint = Number(m[1]);
- });
- }
- child.on("error", (err) => {
- this.appendLog(`[desktop] spawn error: ${err.message}`);
- this.child = null;
- this.lastSpawnEnoent = err.code === "ENOENT";
- if (!settled) { this.setState("crashed", err.message); done(false); }
- });
- child.on("exit", (code, signal) => {
- this.child = null;
- this.appendLog(`[desktop] dsh exited (code=${code} signal=${signal})`);
- if (settled) { this.handleCrash(); return; }
- this.setState("crashed", `启动即退出(code=${code})`);
- done(false);
- });
- const startedAt = Date.now();
- let lastLsof = 0;
- const timer = setInterval(async () => {
- if (this.child !== child) { clearInterval(timer); return; }
- // Readiness requires the child's OWN port — its "dsh web: http://…"
- // line above, or lsof on its pid. Never probe `wanted` directly:
- // a foreign dsh already on that port would fake readiness.
- if (!portHint && child.pid && Date.now() - lastLsof > 2000) {
- lastLsof = Date.now();
- portHint = await this.listenPortOf(child.pid);
- }
- if (portHint && (await probeDsh(portHint))) {
- clearInterval(timer);
- this.port = portHint;
- this.setState("running", `pid ${child.pid}`);
- this.appendLog(`[desktop] service ready at ${this.url}`);
- this.armBackoffReset();
- done(true);
- return;
- }
- if (Date.now() - startedAt > READY_TIMEOUT_MS) {
- clearInterval(timer);
- this.appendLog("[desktop] ready timeout; killing child");
- try { child.kill("SIGKILL"); } catch { /* already gone */ }
- this.setState("crashed", "就绪超时");
- done(false);
- }
- }, 500);
- });
- }
- /** The TCP port a pid listens on — lsof (macOS) or netstat (Windows). */
- listenPortOf(pid) {
- return new Promise((resolve) => {
- if (IS_WIN) {
- // netstat -ano line: " TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING <pid>"
- execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
- if (err || !stdout) return resolve(null);
- for (const line of stdout.split(/\r?\n/)) {
- if (!line.includes("LISTENING")) continue;
- const m = line.match(/127\.0\.0\.1:(\d+)/);
- if (!m) continue;
- const p = Number(line.trim().split(/\s+/).pop());
- if (p === pid) return resolve(Number(m[1]));
- }
- resolve(null);
- });
- return;
- }
- execFile("lsof", ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", String(pid)], (err, stdout) => {
- if (err || !stdout) return resolve(null);
- const m = stdout.match(/127\.0\.0\.1:(\d+)/);
- resolve(m ? Number(m[1]) : null);
- });
- });
- }
- handleCrash() {
- if (this.stopping || this.upgrading || !this.spawned) { this.setState("stopped"); return; }
- const delay = BACKOFF_MS[Math.min(this.backoffIndex, BACKOFF_MS.length - 1)];
- this.backoffIndex += 1;
- this.setState("crashed", `${Math.round(delay / 1000)}s 后自动重启`);
- this.appendLog(`[desktop] crashed; restart in ${delay}ms`);
- setTimeout(() => {
- if (!this.stopping && !this.upgrading && !this.child) this.start(0);
- }, delay);
- }
- armBackoffReset() {
- setTimeout(() => { if (this.running) this.backoffIndex = 0; }, 60_000);
- }
- stop() {
- this.stopping = true;
- if (!this.child) { this.setState("stopped"); return Promise.resolve(true); }
- const child = this.child;
- this.setState("stopping");
- this.appendLog(`[desktop] stopping service (${IS_WIN ? "taskkill" : "SIGTERM"})`);
- return new Promise((resolve) => {
- const killTimer = setTimeout(() => {
- this.appendLog("[desktop] grace elapsed; force kill");
- if (IS_WIN) {
- try { execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"], () => {}); } catch { /* gone */ }
- } else {
- try { child.kill("SIGKILL"); } catch { /* already gone */ }
- }
- }, STOP_GRACE_MS);
- child.once("exit", () => { clearTimeout(killTimer); resolve(true); });
- if (IS_WIN) {
- // Windows has no graceful signal for console node; ask the tree
- // to close (taskkill /T without /F), escalate to /F on timeout.
- try { execFile("taskkill", ["/PID", String(child.pid), "/T"], () => {}); }
- catch { clearTimeout(killTimer); resolve(false); }
- } else {
- try { child.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(false); }
- }
- }).then(() => {
- this.child = null;
- this.port = null;
- this.spawned = false;
- this.setState("stopped");
- return true;
- });
- }
- /** Restart: our child stops gracefully; an attached foreign instance is taken over. */
- async restart() {
- if (this.child) {
- await this.stop();
- return this.start(DEFAULT_PORT);
- }
- if (this.port) {
- this.appendLog(`[desktop] taking over the attached service on :${this.port}`);
- await this.killByPort(this.port);
- this.port = null;
- this.spawned = false;
- return this.start(DEFAULT_PORT);
- }
- return this.start(DEFAULT_PORT);
- }
- /** Stop whatever listens on a port (explicit take-over only). */
- killByPort(port) {
- return new Promise((resolve) => {
- const collect = (cb) => {
- if (IS_WIN) {
- execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
- if (err || !stdout) return cb([]);
- const pids = [];
- for (const line of stdout.split(/\r?\n/)) {
- if (!line.includes("LISTENING")) continue;
- const m = line.match(/127\.0\.0\.1:(\d+)/);
- if (!m || Number(m[1]) !== port) continue;
- const p = Number(line.trim().split(/\s+/).pop());
- if (p) pids.push(p);
- }
- cb(pids);
- });
- } else {
- execFile("lsof", ["-ti", `tcp:${port}`], (err, stdout) => {
- cb((stdout || "").trim().split(/\s+/).filter(Boolean).map(Number));
- });
- }
- };
- collect((pids) => {
- if (pids.length === 0) return resolve(false);
- this.appendLog(`[desktop] stopping pids ${pids.join(", ")} on :${port}`);
- if (IS_WIN) {
- for (const pid of pids) execFile("taskkill", ["/PID", String(pid), "/T", "/F"], () => {});
- } else {
- for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
- }
- const startedAt = Date.now();
- const iv = setInterval(async () => {
- const alive = await probeDsh(port);
- if (!alive || Date.now() - startedAt > 10_000) {
- clearInterval(iv);
- if (alive && !IS_WIN) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
- resolve(true);
- }
- }, 400);
- });
- });
- }
- }
- module.exports = { Service, probeDsh, DEFAULT_PORT, DSH_BIN };
|