service.js 11 KB

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