|
|
@@ -6,17 +6,19 @@
|
|
|
* 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 fallback).
|
|
|
- * - crash: restart with exponential backoff (suppressed while stopping or
|
|
|
+ * 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.
|
|
|
+ * - 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 } = require("./env");
|
|
|
+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;
|
|
|
@@ -101,11 +103,22 @@ class Service {
|
|
|
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" }).trim(); } catch { /* unknown */ }
|
|
|
+ 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;
|
|
|
@@ -145,11 +158,17 @@ class Service {
|
|
|
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(bin, ["web", "--port", String(wanted)], {
|
|
|
+ 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";
|
|
|
@@ -211,9 +230,24 @@ class Service {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- /** The TCP port a pid listens on, via lsof (macOS). */
|
|
|
+ /** 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+)/);
|
|
|
@@ -242,14 +276,25 @@ class Service {
|
|
|
if (!this.child) { this.setState("stopped"); return Promise.resolve(true); }
|
|
|
const child = this.child;
|
|
|
this.setState("stopping");
|
|
|
- this.appendLog("[desktop] stopping service (SIGTERM)");
|
|
|
+ this.appendLog(`[desktop] stopping service (${IS_WIN ? "taskkill" : "SIGTERM"})`);
|
|
|
return new Promise((resolve) => {
|
|
|
const killTimer = setTimeout(() => {
|
|
|
- this.appendLog("[desktop] grace elapsed; SIGKILL");
|
|
|
- try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
|
+ 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); });
|
|
|
- try { child.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(false); }
|
|
|
+ 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;
|
|
|
@@ -275,20 +320,43 @@ class Service {
|
|
|
return this.start(DEFAULT_PORT);
|
|
|
}
|
|
|
|
|
|
- /** SIGTERM whatever listens on a port (used only for explicit take-over). */
|
|
|
+ /** Stop whatever listens on a port (explicit take-over only). */
|
|
|
killByPort(port) {
|
|
|
return new Promise((resolve) => {
|
|
|
- execFile("lsof", ["-ti", `tcp:${port}`], (err, stdout) => {
|
|
|
- const pids = (stdout || "").trim().split(/\s+/).filter(Boolean).map(Number);
|
|
|
- if (err || pids.length === 0) return resolve(false);
|
|
|
- this.appendLog(`[desktop] SIGTERM pid ${pids.join(", ")} (port ${port})`);
|
|
|
- for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
|
|
|
+ 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) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
|
|
|
+ if (alive && !IS_WIN) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
|
|
|
resolve(true);
|
|
|
}
|
|
|
}, 400);
|