service.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. "use strict";
  2. /**
  3. * Supervisor for a local `dsh web` service.
  4. *
  5. * - attach: if a healthy dsh is already listening on the default port (its
  6. * HTML shell carries `__DSH_BOOT__`), use it and own nothing.
  7. * - spawn: otherwise start `dsh web --port <n>` as a supervised child; a busy
  8. * default port falls back to `--port 0` (the actual port is parsed from the
  9. * child's "dsh web: http://127.0.0.1:<port>" line, with an lsof/netstat
  10. * fallback).
  11. * - crash: restart with exponential backoff (supervised while stopping or
  12. * upgrading, and never for an instance we did not spawn).
  13. * - stop: SIGTERM first — dsh shuts down gracefully — then SIGKILL on timeout
  14. * (Windows: taskkill /T, which has no graceful-signal equivalent).
  15. */
  16. const { spawn, execFile, execFileSync } = require("node:child_process");
  17. const http = require("node:http");
  18. const fs = require("node:fs");
  19. const path = require("node:path");
  20. const readline = require("node:readline");
  21. const { DSH_BIN, resolveDsh, childEnv, IS_WIN } = require("./env");
  22. const DEFAULT_PORT = Number(process.env.DSH_DESKTOP_PORT || 3080);
  23. const READY_TIMEOUT_MS = 120_000;
  24. const STOP_GRACE_MS = 8_000;
  25. const BACKOFF_MS = [1_000, 5_000, 30_000, 60_000];
  26. /** Small GET with timeout; never rejects. */
  27. function httpGet(url, timeoutMs = 2000) {
  28. return new Promise((resolve) => {
  29. let settled = false;
  30. const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
  31. const req = http.get(url, (res) => {
  32. let body = "";
  33. res.on("data", (chunk) => {
  34. body += chunk;
  35. if (body.length >= 16384) { req.destroy(); finish({ ok: true, status: res.statusCode, body }); }
  36. });
  37. res.on("end", () => finish({ ok: true, status: res.statusCode, body }));
  38. res.on("error", () => finish({ ok: true, status: res.statusCode, body }));
  39. });
  40. req.on("error", () => finish({ ok: false }));
  41. req.setTimeout(timeoutMs, () => { req.destroy(); finish({ ok: false }); });
  42. });
  43. }
  44. /** A healthy dsh web UI answers 2xx and its HTML shell carries the boot marker. */
  45. async function probeDsh(port) {
  46. const res = await httpGet(`http://127.0.0.1:${port}/`);
  47. return Boolean(res.ok && res.status && res.status < 500 && res.body.includes("__DSH_BOOT__"));
  48. }
  49. class Service {
  50. constructor({ userDataDir, onState, onLog }) {
  51. this.userDataDir = userDataDir;
  52. this.onState = onState || (() => {});
  53. this.onLog = onLog || (() => {});
  54. this.child = null;
  55. this.port = null;
  56. this.spawned = false; // did we start this dsh?
  57. this.state = "stopped"; // stopped | starting | running | stopping | crashed
  58. this.detail = "";
  59. this.upgrading = false; // suppress crash-restart while the updater works
  60. this.stopping = false;
  61. this.backoffIndex = 0;
  62. this.logLines = [];
  63. this.logFile = path.join(userDataDir, "dsh-web.log");
  64. this._ver = null;
  65. this._verAt = 0;
  66. }
  67. get url() { return this.port ? `http://127.0.0.1:${this.port}` : null; }
  68. get running() { return this.state === "running"; }
  69. setState(state, detail = "") {
  70. this.state = state;
  71. this.detail = detail;
  72. this.onState(this);
  73. }
  74. appendLog(line) {
  75. this.logLines.push(line);
  76. if (this.logLines.length > 500) this.logLines.shift();
  77. this.onLog(line);
  78. try { fs.appendFileSync(this.logFile, line + "\n"); } catch { /* disk issues must not kill the shell */ }
  79. }
  80. ensureLogFile() {
  81. try { fs.closeSync(fs.openSync(this.logFile, "a")); } catch { /* ignore */ }
  82. return this.logFile;
  83. }
  84. /** Current @deepseek-ai/dsh version, read from its package.json (cached). */
  85. version() {
  86. const now = Date.now();
  87. if (this._ver && now - this._verAt < 10_000) return this._ver;
  88. let version = null;
  89. try {
  90. let dir = path.dirname(fs.realpathSync(DSH_BIN));
  91. for (let i = 0; i < 8 && dir !== path.dirname(dir); i++) {
  92. const manifest = path.join(dir, "package.json");
  93. if (fs.existsSync(manifest)) {
  94. const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
  95. if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
  96. }
  97. // Windows npm layout: <prefix>/dsh.cmd + <prefix>/node_modules/@deepseek-ai/dsh
  98. const nm = path.join(dir, "node_modules", "@deepseek-ai", "dsh", "package.json");
  99. if (fs.existsSync(nm)) {
  100. const pkg = JSON.parse(fs.readFileSync(nm, "utf8"));
  101. if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
  102. }
  103. dir = path.dirname(dir);
  104. }
  105. } catch { /* fall through to the CLI */ }
  106. if (!version) {
  107. try {
  108. version = execFileSync(DSH_BIN, ["-V"], {
  109. encoding: "utf8",
  110. shell: IS_WIN, // .cmd shims need cmd.exe
  111. }).trim();
  112. } catch { /* unknown */ }
  113. }
  114. this._ver = version;
  115. this._verAt = now;
  116. return version;
  117. }
  118. /** Attach to a healthy instance, else spawn (fixed port first, then a free one).
  119. * ENOENT retries cover the `npm install -g` window: while npm rewrites the
  120. * bin symlink the spawn target briefly does not exist. */
  121. async ensure() {
  122. if (process.env.DSH_DESKTOP_NO_ATTACH !== "1" && await probeDsh(DEFAULT_PORT)) {
  123. this.port = DEFAULT_PORT;
  124. this.spawned = false;
  125. this.setState("running", "attached");
  126. this.appendLog(`[desktop] attached to an existing dsh at ${this.url}`);
  127. return true;
  128. }
  129. for (let attempt = 0; attempt < 6; attempt++) {
  130. const wanted = attempt === 0 ? DEFAULT_PORT : 0;
  131. if (await this.start(wanted)) return true;
  132. // ENOENT (bin link mid-rewrite) is worth waiting out; other failures
  133. // stop after the usual port fallback.
  134. if (!this.lastSpawnEnoent && attempt >= 1) return false;
  135. this.lastSpawnEnoent = false;
  136. this.appendLog(`[desktop] spawn target missing; retry ${attempt + 1}/5 in 3s`);
  137. await new Promise((r) => setTimeout(r, 3000));
  138. }
  139. return false;
  140. }
  141. start(wanted = DEFAULT_PORT) {
  142. if (this.child || this.running) return Promise.resolve(true);
  143. this.stopping = false;
  144. this.setState("starting", wanted === 0 ? "spawn · free port" : `spawn · :${wanted}`);
  145. return new Promise((resolve) => {
  146. let settled = false;
  147. let portHint = null;
  148. const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };
  149. const bin = resolveDsh(); // re-resolved per spawn: npm upgrades replace the link
  150. // On Windows, dsh is a .cmd shim — spawn via cmd.exe (shell:true). We
  151. // pass the bare name so cmd resolves it through the augmented PATH,
  152. // sidestepping spaces in absolute install paths.
  153. const spawnBin = IS_WIN ? "dsh" : bin;
  154. let child;
  155. try {
  156. child = spawn(spawnBin, ["web", "--port", String(wanted)], {
  157. stdio: ["ignore", "pipe", "pipe"],
  158. env: childEnv(),
  159. shell: IS_WIN ? true : false,
  160. windowsHide: true,
  161. });
  162. } catch (err) {
  163. this.lastSpawnEnoent = err.code === "ENOENT";
  164. this.setState("crashed", err.message);
  165. done(false);
  166. return;
  167. }
  168. this.child = child;
  169. this.spawned = true;
  170. this.appendLog(`[desktop] spawn: ${bin} web --port ${wanted} (pid ${child.pid})`);
  171. for (const stream of [child.stdout, child.stderr]) {
  172. readline.createInterface({ input: stream }).on("line", (line) => {
  173. if (line) this.appendLog(line);
  174. const m = line.match(/http:\/\/127\.0\.0\.1:(\d+)/);
  175. if (m) portHint = Number(m[1]);
  176. });
  177. }
  178. child.on("error", (err) => {
  179. this.appendLog(`[desktop] spawn error: ${err.message}`);
  180. this.child = null;
  181. this.lastSpawnEnoent = err.code === "ENOENT";
  182. if (!settled) { this.setState("crashed", err.message); done(false); }
  183. });
  184. child.on("exit", (code, signal) => {
  185. this.child = null;
  186. this.appendLog(`[desktop] dsh exited (code=${code} signal=${signal})`);
  187. if (settled) { this.handleCrash(); return; }
  188. this.setState("crashed", `启动即退出(code=${code})`);
  189. done(false);
  190. });
  191. const startedAt = Date.now();
  192. let lastLsof = 0;
  193. const timer = setInterval(async () => {
  194. if (this.child !== child) { clearInterval(timer); return; }
  195. // Readiness requires the child's OWN port — its "dsh web: http://…"
  196. // line above, or lsof on its pid. Never probe `wanted` directly:
  197. // a foreign dsh already on that port would fake readiness.
  198. if (!portHint && child.pid && Date.now() - lastLsof > 2000) {
  199. lastLsof = Date.now();
  200. portHint = await this.listenPortOf(child.pid);
  201. }
  202. if (portHint && (await probeDsh(portHint))) {
  203. clearInterval(timer);
  204. this.port = portHint;
  205. this.setState("running", `pid ${child.pid}`);
  206. this.appendLog(`[desktop] service ready at ${this.url}`);
  207. this.armBackoffReset();
  208. done(true);
  209. return;
  210. }
  211. if (Date.now() - startedAt > READY_TIMEOUT_MS) {
  212. clearInterval(timer);
  213. this.appendLog("[desktop] ready timeout; killing child");
  214. try { child.kill("SIGKILL"); } catch { /* already gone */ }
  215. this.setState("crashed", "就绪超时");
  216. done(false);
  217. }
  218. }, 500);
  219. });
  220. }
  221. /** The TCP port a pid listens on — lsof (macOS) or netstat (Windows). */
  222. listenPortOf(pid) {
  223. return new Promise((resolve) => {
  224. if (IS_WIN) {
  225. // netstat -ano line: " TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING <pid>"
  226. execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
  227. if (err || !stdout) return resolve(null);
  228. for (const line of stdout.split(/\r?\n/)) {
  229. if (!line.includes("LISTENING")) continue;
  230. const m = line.match(/127\.0\.0\.1:(\d+)/);
  231. if (!m) continue;
  232. const p = Number(line.trim().split(/\s+/).pop());
  233. if (p === pid) return resolve(Number(m[1]));
  234. }
  235. resolve(null);
  236. });
  237. return;
  238. }
  239. execFile("lsof", ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", String(pid)], (err, stdout) => {
  240. if (err || !stdout) return resolve(null);
  241. const m = stdout.match(/127\.0\.0\.1:(\d+)/);
  242. resolve(m ? Number(m[1]) : null);
  243. });
  244. });
  245. }
  246. handleCrash() {
  247. if (this.stopping || this.upgrading || !this.spawned) { this.setState("stopped"); return; }
  248. const delay = BACKOFF_MS[Math.min(this.backoffIndex, BACKOFF_MS.length - 1)];
  249. this.backoffIndex += 1;
  250. this.setState("crashed", `${Math.round(delay / 1000)}s 后自动重启`);
  251. this.appendLog(`[desktop] crashed; restart in ${delay}ms`);
  252. setTimeout(() => {
  253. if (!this.stopping && !this.upgrading && !this.child) this.start(0);
  254. }, delay);
  255. }
  256. armBackoffReset() {
  257. setTimeout(() => { if (this.running) this.backoffIndex = 0; }, 60_000);
  258. }
  259. stop() {
  260. this.stopping = true;
  261. if (!this.child) { this.setState("stopped"); return Promise.resolve(true); }
  262. const child = this.child;
  263. this.setState("stopping");
  264. this.appendLog(`[desktop] stopping service (${IS_WIN ? "taskkill" : "SIGTERM"})`);
  265. return new Promise((resolve) => {
  266. const killTimer = setTimeout(() => {
  267. this.appendLog("[desktop] grace elapsed; force kill");
  268. if (IS_WIN) {
  269. try { execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"], () => {}); } catch { /* gone */ }
  270. } else {
  271. try { child.kill("SIGKILL"); } catch { /* already gone */ }
  272. }
  273. }, STOP_GRACE_MS);
  274. child.once("exit", () => { clearTimeout(killTimer); resolve(true); });
  275. if (IS_WIN) {
  276. // Windows has no graceful signal for console node; ask the tree
  277. // to close (taskkill /T without /F), escalate to /F on timeout.
  278. try { execFile("taskkill", ["/PID", String(child.pid), "/T"], () => {}); }
  279. catch { clearTimeout(killTimer); resolve(false); }
  280. } else {
  281. try { child.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(false); }
  282. }
  283. }).then(() => {
  284. this.child = null;
  285. this.port = null;
  286. this.spawned = false;
  287. this.setState("stopped");
  288. return true;
  289. });
  290. }
  291. /** Restart: our child stops gracefully; an attached foreign instance is taken over. */
  292. async restart() {
  293. if (this.child) {
  294. await this.stop();
  295. return this.start(DEFAULT_PORT);
  296. }
  297. if (this.port) {
  298. this.appendLog(`[desktop] taking over the attached service on :${this.port}`);
  299. await this.killByPort(this.port);
  300. this.port = null;
  301. this.spawned = false;
  302. return this.start(DEFAULT_PORT);
  303. }
  304. return this.start(DEFAULT_PORT);
  305. }
  306. /** Stop whatever listens on a port (explicit take-over only). */
  307. killByPort(port) {
  308. return new Promise((resolve) => {
  309. const collect = (cb) => {
  310. if (IS_WIN) {
  311. execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
  312. if (err || !stdout) return cb([]);
  313. const pids = [];
  314. for (const line of stdout.split(/\r?\n/)) {
  315. if (!line.includes("LISTENING")) continue;
  316. const m = line.match(/127\.0\.0\.1:(\d+)/);
  317. if (!m || Number(m[1]) !== port) continue;
  318. const p = Number(line.trim().split(/\s+/).pop());
  319. if (p) pids.push(p);
  320. }
  321. cb(pids);
  322. });
  323. } else {
  324. execFile("lsof", ["-ti", `tcp:${port}`], (err, stdout) => {
  325. cb((stdout || "").trim().split(/\s+/).filter(Boolean).map(Number));
  326. });
  327. }
  328. };
  329. collect((pids) => {
  330. if (pids.length === 0) return resolve(false);
  331. this.appendLog(`[desktop] stopping pids ${pids.join(", ")} on :${port}`);
  332. if (IS_WIN) {
  333. for (const pid of pids) execFile("taskkill", ["/PID", String(pid), "/T", "/F"], () => {});
  334. } else {
  335. for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
  336. }
  337. const startedAt = Date.now();
  338. const iv = setInterval(async () => {
  339. const alive = await probeDsh(port);
  340. if (!alive || Date.now() - startedAt > 10_000) {
  341. clearInterval(iv);
  342. if (alive && !IS_WIN) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
  343. resolve(true);
  344. }
  345. }, 400);
  346. });
  347. });
  348. }
  349. }
  350. module.exports = { Service, probeDsh, DEFAULT_PORT, DSH_BIN };