tt-help-cli-ycl 1.4.172 → 1.4.175

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * 登录池节点侧执行器 — applyLogin / clearLogin 的真实浏览器操作
3
+ *
4
+ * 流程(applyLogin,F-1→F-11 实证 SOP,见知识库 topics/edge-login-transfer.md):
5
+ * 1. 清理该端口残留 Edge(占 user-data-dir 的旧实例必须先杀,否则 ProcessSingleton 转发)
6
+ * 2. ensureBrowserReady 起 Edge(CDP + 代理出口;代理 = proxyHint > 节点配置 proxy)
7
+ * 3. CDP context.addCookies 注入明文 cookies
8
+ * 4. 活实例验证:account/info 接口拿 username(拿到 = 已登录)
9
+ * 5. 等 ≥35s(Edge flush 周期)→ browser.close() 优雅退出(强杀必丢内存态,实证坑)
10
+ * 6. 重启复验:从磁盘重新加载 → 再验 username(验证持久化成功)
11
+ * 7. 全部通过后关闭浏览器(磁盘已有登录态,业务进程下次启动直接带登录)
12
+ *
13
+ * clearLogin: 杀 Edge → 删 Cookies 数据库(Default/Network 两位置都清)
14
+ *
15
+ * 安全: cookies 明文只在内存流转,不写任何临时文件;日志只打 username/端口。
16
+ */
17
+
18
+ import os from "os";
19
+ import path from "path";
20
+ import { existsSync, rmSync } from "fs";
21
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
22
+
23
+ const PERSIST_WAIT_MS = 35 * 1000; // Edge cookie flush 周期裕量(实证 ≥35s)
24
+ const ACCOUNT_INFO_URL = "https://www.tiktok.com/passport/web/account/info/";
25
+
26
+ /** 与 login-check.js / open.js 一致的端口→profile 目录约定 */
27
+ export function userDataDirForPort(port) {
28
+ return path.join(
29
+ os.homedir(),
30
+ "Library",
31
+ "Application Support",
32
+ `Microsoft Edge For Testing_p${port}`,
33
+ );
34
+ }
35
+
36
+ /** 硬超时:部分 CDP 操作(如对僵死渲染进程的 page.close)会无限挂起,必须到点打断 */
37
+ function withTimeout(promise, ms, label) {
38
+ return Promise.race([
39
+ Promise.resolve(promise),
40
+ new Promise((_, rej) =>
41
+ setTimeout(() => rej(new Error(`${label} 超时(${Math.round(ms / 1000)}s)`)), ms),
42
+ ),
43
+ ]);
44
+ }
45
+
46
+ /** proxyHint 归一化:"7897" → "http://127.0.0.1:7897" */
47
+ function normalizeProxy(proxyHint, configProxy) {
48
+ if (proxyHint) {
49
+ const s = String(proxyHint).trim();
50
+ if (/^\d+$/.test(s)) return `http://127.0.0.1:${s}`;
51
+ if (/^https?:\/\//.test(s)) return s;
52
+ if (/^[\d.]+:\d+$/.test(s)) return `http://${s}`;
53
+ }
54
+ return configProxy || null;
55
+ }
56
+
57
+ /** 活实例登录验证:page 上下文 fetch account/info,拿 username 即已登录 */
58
+ async function verifyLoggedIn(context) {
59
+ const page = await context.newPage();
60
+ try {
61
+ await page.goto("https://www.tiktok.com/", {
62
+ waitUntil: "domcontentloaded",
63
+ timeout: 30000,
64
+ });
65
+ const r = await page.evaluate(
66
+ async ({ url, timeout }) => {
67
+ try {
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), timeout);
70
+ const res = await fetch(url, {
71
+ signal: controller.signal,
72
+ credentials: "include",
73
+ headers: { accept: "application/json" },
74
+ });
75
+ clearTimeout(timer);
76
+ let data = null;
77
+ try {
78
+ data = await res.json();
79
+ } catch {}
80
+ const d = data?.data || null;
81
+ return {
82
+ username: d?.username || d?.unique_id || null,
83
+ hasAccount: !!d && !!(d.user_id || d.sec_user_id),
84
+ expired: d?.name === "session_expired" || d?.error_code === 13,
85
+ };
86
+ } catch (e) {
87
+ return { username: null, hasAccount: false, expired: false, error: String(e) };
88
+ }
89
+ },
90
+ { url: ACCOUNT_INFO_URL, timeout: 10000 },
91
+ );
92
+ return r;
93
+ } finally {
94
+ await withTimeout(page.close(), 5000, "page.close").catch(() => {});
95
+ }
96
+ }
97
+
98
+ /** 杀掉占用该端口的 Edge(精确 user-data-dir 匹配,不误伤其他实例) */
99
+ async function stopEdgeForPort(port) {
100
+ const dir = userDataDirForPort(port);
101
+ try {
102
+ await killEdgeProcesses(dir, port);
103
+ } catch (e) {
104
+ // killEdgeProcesses 在无匹配进程时可能报错,忽略
105
+ }
106
+ await new Promise((r) => setTimeout(r, 3000));
107
+ }
108
+
109
+ /**
110
+ * 向指定端口的 Edge profile 注入登录态并验证持久化(带总死线,防止单次操作卡死整个命令队列)
111
+ * @returns {Promise<{ok: boolean, username?: string, persisted?: boolean, steps?: string[], error?: string}>}
112
+ */
113
+ export async function applyLoginToPort(opts) {
114
+ const port = Number(opts.port);
115
+ try {
116
+ // 总死线 240s:正常全程 ≈80s(启动+注入+验证+35s flush+重启复验),2倍余量足够
117
+ return await withTimeout(applyLoginToPortInner(opts), 240000, `applyLogin@${port}`);
118
+ } catch (e) {
119
+ // 超时/异常:强清现场(杀该端口 Edge),避免留下僵尸实例占住 CDP
120
+ try {
121
+ await withTimeout(stopEdgeForPort(port), 15000, "stopEdge");
122
+ } catch {}
123
+ return { ok: false, error: e.message, steps: [] };
124
+ }
125
+ }
126
+
127
+ async function applyLoginToPortInner({ port, cookies, proxyHint, username, configProxy }) {
128
+ const steps = [];
129
+ const log = (s) => {
130
+ steps.push(s);
131
+ console.error(`[登录池] ${s}`);
132
+ };
133
+ try {
134
+ if (!Array.isArray(cookies) || cookies.length === 0) {
135
+ return { ok: false, error: "cookies 为空", steps };
136
+ }
137
+ const proxyServer = normalizeProxy(proxyHint, configProxy);
138
+ const dir = userDataDirForPort(port);
139
+
140
+ // 1. 清理残留实例
141
+ log(`端口 ${port}: 清理残留 Edge...`);
142
+ await stopEdgeForPort(port);
143
+
144
+ // 2. 起 Edge(CDP + 代理)
145
+ log(`端口 ${port}: 启动 Edge${proxyServer ? ` (代理 ${proxyServer})` : ""}...`);
146
+ const browser = await ensureBrowserReady({ port, userDataDir: dir, proxyServer });
147
+ const context = browser.contexts()[0] || (await browser.newContext());
148
+
149
+ // 3. 注入(先清旧 cookie,避免同域残留干扰判定)
150
+ log(`端口 ${port}: 注入 ${cookies.length} 条 cookies...`);
151
+ try {
152
+ await context.clearCookies();
153
+ } catch {}
154
+ await context.addCookies(
155
+ cookies.map((c) => ({
156
+ name: c.name,
157
+ value: c.value,
158
+ domain: c.domain || ".tiktok.com",
159
+ path: c.path || "/",
160
+ secure: c.secure !== false,
161
+ httpOnly: c.httpOnly !== false,
162
+ ...(typeof c.expires === "number" && c.expires > 0 ? { expires: c.expires } : {}),
163
+ })),
164
+ );
165
+
166
+ // 4. 活实例验证
167
+ log(`端口 ${port}: 活实例验证...`);
168
+ const v = await verifyLoggedIn(context);
169
+ if (!v.username) {
170
+ await withTimeout(browser.close(), 20000, "browser.close").catch(() => {});
171
+ return {
172
+ ok: false,
173
+ error: `注入后未登录(${v.expired ? "会话过期" : v.error || "无账号数据"})`,
174
+ steps,
175
+ };
176
+ }
177
+ log(`端口 ${port}: 已登录 @${v.username} ✓`);
178
+
179
+ // 5. 等 flush + 优雅退出
180
+ log(`端口 ${port}: 等待 ${PERSIST_WAIT_MS / 1000}s flush 后优雅退出...`);
181
+ await new Promise((r) => setTimeout(r, PERSIST_WAIT_MS));
182
+ await withTimeout(browser.close(), 30000, "browser.close(优雅落盘)").catch(() => {});
183
+ await new Promise((r) => setTimeout(r, 5000));
184
+
185
+ // 6. 重启复验(持久化验证)
186
+ log(`端口 ${port}: 重启复验(磁盘加载)...`);
187
+ const browser2 = await ensureBrowserReady({ port, userDataDir: dir, proxyServer });
188
+ const context2 = browser2.contexts()[0] || (await browser2.newContext());
189
+ const v2 = await verifyLoggedIn(context2);
190
+ let persisted = !!v2.username;
191
+ if (persisted) {
192
+ log(`端口 ${port}: 持久化复验 @${v2.username} ✓`);
193
+ } else {
194
+ log(`端口 ${port}: ⚠️ 重启后未保持登录(${v2.expired ? "会话过期" : v2.error || "无账号数据"})`);
195
+ }
196
+ await withTimeout(browser2.close(), 20000, "browser2.close").catch(() => {});
197
+ await new Promise((r) => setTimeout(r, 3000));
198
+
199
+ return { ok: true, username: v.username, persisted, steps };
200
+ } catch (e) {
201
+ return { ok: false, error: e.message, steps };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 清除指定端口 profile 的 TikTok 登录态(杀 Edge + 删 Cookies 库)
207
+ * @returns {Promise<{ok: boolean, error?: string}>}
208
+ */
209
+ export async function clearLoginOnPort({ port }) {
210
+ try {
211
+ const dir = userDataDirForPort(port);
212
+ await stopEdgeForPort(port);
213
+ for (const rel of [
214
+ path.join("Default", "Cookies"),
215
+ path.join("Default", "Network", "Cookies"),
216
+ ]) {
217
+ const f = path.join(dir, rel);
218
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
219
+ const p = f + suffix;
220
+ if (existsSync(p)) {
221
+ try {
222
+ rmSync(p);
223
+ } catch {}
224
+ }
225
+ }
226
+ }
227
+ console.error(`[登录池] 端口 ${port}: 已清除登录态`);
228
+ return { ok: true };
229
+ } catch (e) {
230
+ return { ok: false, error: e.message };
231
+ }
232
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 登录态导入(节点 → 账号中心)— 看门狗启动时独立执行的一步
3
+ *
4
+ * 与 login-check 等其他逻辑零耦合:对登录检测配置的端口(loginPorts,服务端下发)
5
+ * 逐口「起/附 Edge → passport 验证 → 导出 cookies → 上传账号中心」。
6
+ *
7
+ * 服务端语义(以号为主键,POST /api/login-pool/import):
8
+ * - 新号 → 入池为【空闲】(导入只是登记,租约只由真实消费产生;pool 模式
9
+ * 消费方启动时自会 acquire)
10
+ * - 已在池 → 幂等跳过(不覆盖、不动租约);服务端记录 lastImportFrom 来源元数据
11
+ *
12
+ * 同步自己起的探测 Edge 在导出后关掉(不残留吃内存);本来就跑着的不动。
13
+ * 任何失败仅记录,不阻塞看门狗启动。
14
+ */
15
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
16
+ import { gotoTiktok, whoAmI } from "../lib/login-pool-client.js";
17
+ import { userDataDirForPort } from "./login-apply.js";
18
+ import { readFileSync, writeFileSync } from "fs";
19
+ import { homedir } from "os";
20
+ import { join } from "path";
21
+
22
+ // 本地已导入标识:记录端口 → 已同步的登录号。写入即永久跳过(无论账号中心后续状态如何),
23
+ // 保证「永远只导入一次」;文件损坏按无标识处理(重新逐口探测,服务端去重兜底)
24
+ const MARKER_FILE = join(homedir(), ".tt-help-watchdog-login-imported.json");
25
+
26
+ function loadMarker() {
27
+ try {
28
+ const d = JSON.parse(readFileSync(MARKER_FILE, "utf-8"));
29
+ return d && typeof d === "object" ? d : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function saveMarker(marker) {
36
+ try {
37
+ writeFileSync(MARKER_FILE, JSON.stringify(marker, null, 2), "utf-8");
38
+ } catch (e) {
39
+ console.error(`[登录导入] 标识写入失败(不影响导入): ${e.message}`);
40
+ }
41
+ }
42
+
43
+ /** 硬超时:Edge 启动/导航可能挂死,到点打断 */
44
+ function withTimeout(promise, ms, label) {
45
+ return Promise.race([
46
+ Promise.resolve(promise),
47
+ new Promise((_, rej) =>
48
+ setTimeout(() => rej(new Error(`${label} 超时(${Math.round(ms / 1000)}s)`)), ms),
49
+ ),
50
+ ]);
51
+ }
52
+
53
+ /** 单口导入:起/附 Edge → 验证 → 导出 cookies → 上传。未登录返回 skip */
54
+ async function importPort({ serverUrl, userId, port, proxyServer }) {
55
+ // 探测该口是否已有 Edge 在跑:同步自己起的,导出完关掉;本来就跑着的不动
56
+ let selfStarted = false;
57
+ try {
58
+ await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(2000) });
59
+ } catch {
60
+ selfStarted = true;
61
+ }
62
+ const browser = await ensureBrowserReady({
63
+ port,
64
+ userDataDir: userDataDirForPort(port),
65
+ ...(proxyServer ? { proxyServer } : {}),
66
+ });
67
+ const context = browser.contexts()[0] || (await browser.newContext());
68
+ const page = await context.newPage();
69
+ try {
70
+ await gotoTiktok(page);
71
+ const id = await whoAmI(page);
72
+ if (!id.hasAccount) {
73
+ return { ok: false, skip: true, reason: id.expired ? "会话过期" : "未登录" };
74
+ }
75
+ // 注意:未登录口不写标识——以后人工登录了还能被导入
76
+ const cookies = await context.cookies();
77
+ const res = await fetch(
78
+ `${String(serverUrl).replace(/\/+$/, "")}/api/login-pool/import`,
79
+ {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ userId,
84
+ port,
85
+ username: id.username,
86
+ cookies,
87
+ note: `节点启动同步 ${new Date().toISOString().slice(0, 10)}`,
88
+ }),
89
+ signal: AbortSignal.timeout(30000),
90
+ },
91
+ );
92
+ const j = await res.json().catch(() => null);
93
+ if (!res.ok || !j || j.error) throw new Error((j && j.error) || `HTTP ${res.status}`);
94
+ // 成功(含 dedup:中心已有即视为已同步)→ 写本地标识,永久只导一次
95
+ marker[port] = j.account.username;
96
+ saveMarker(marker);
97
+ return { ok: true, dedup: !!j.dedup, username: j.account.username, status: j.account.status };
98
+ } finally {
99
+ await page.close().catch(() => {}); // 只关自建页,不清光页面(POC 坑②)
100
+ if (selfStarted) {
101
+ // 关掉同步自己起的实例(此刻业务未启动,无占用风险);本来跑着的不动
102
+ await killEdgeProcesses(userDataDirForPort(port), port).catch(() => {});
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 同步入口:逐口导入本节点登录态。
109
+ * @param {object} opts { serverUrl, userId, ports[], proxyServer?, portTimeoutMs?, totalTimeoutMs? }
110
+ * @returns {Promise<{imported: Array, skipped: Array, failed: Array}>}
111
+ */
112
+ export async function syncLoginStates(opts = {}) {
113
+ const {
114
+ serverUrl,
115
+ userId,
116
+ ports = [],
117
+ proxyServer = null,
118
+ portTimeoutMs = 90 * 1000,
119
+ totalTimeoutMs = 5 * 60 * 1000,
120
+ } = opts;
121
+ const summary = { imported: [], skipped: [], failed: [] };
122
+ if (!serverUrl || !userId || !Array.isArray(ports) || ports.length === 0) return summary;
123
+ const deadline = Date.now() + totalTimeoutMs;
124
+
125
+ // 中心视图:本节点已租的口直接跳过(二次启动热路径,免重复起 Edge)
126
+ // 热路径:本节点此前已成功导入过的口(lastImportFrom 元数据)直接跳过,免重复起 Edge
127
+ const imported = new Set();
128
+ try {
129
+ const r = await fetch(`${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`, {
130
+ signal: AbortSignal.timeout(15000),
131
+ });
132
+ const j = await r.json();
133
+ for (const a of j.accounts || []) {
134
+ const m = a.lastImportFrom;
135
+ if (m && m.userId === userId && ports.includes(Number(m.port))) {
136
+ imported.add(Number(m.port));
137
+ }
138
+ }
139
+ } catch (e) {
140
+ console.error(`[登录导入] 中心视图获取失败(继续逐口尝试): ${e.message}`);
141
+ }
142
+
143
+ for (const port of ports) {
144
+ const p = Number(port);
145
+ if (Date.now() > deadline) {
146
+ summary.failed.push({ port: p, reason: "总超时" });
147
+ console.error(`[登录导入] 端口 ${p}: 总超时,剩余端口放弃`);
148
+ continue;
149
+ }
150
+ if (imported.has(p)) {
151
+ summary.skipped.push({ port: p, reason: "本节点此前已导入" });
152
+ console.error(`[登录导入] 端口 ${p}: 此前已导入,跳过`);
153
+ continue;
154
+ }
155
+ console.error(`[登录导入] 端口 ${p}: 检测并导入...`);
156
+ try {
157
+ const r = await withTimeout(
158
+ importPort({ serverUrl, userId, port: p, proxyServer }),
159
+ portTimeoutMs,
160
+ `端口 ${p} 导入`,
161
+ );
162
+ if (r.ok) {
163
+ summary.imported.push({ port: p, username: r.username, dedup: r.dedup });
164
+ console.error(
165
+ `[登录导入] 端口 ${p}: @${r.username} ${r.dedup ? "已在池,幂等跳过" : "新入池(空闲)✓"}`,
166
+ );
167
+ } else {
168
+ summary.skipped.push({ port: p, reason: r.reason });
169
+ console.error(`[登录导入] 端口 ${p}: 跳过(${r.reason})`);
170
+ }
171
+ } catch (e) {
172
+ summary.failed.push({ port: p, reason: e.message });
173
+ console.error(`[登录导入] 端口 ${p}: 失败(${e.message})`);
174
+ }
175
+ }
176
+ return summary;
177
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { exec, spawn, execFile } from "child_process";
10
10
  import { promisify } from "util";
11
- import { readFileSync, writeFileSync, openSync, unlinkSync } from "fs";
11
+ import { existsSync, readFileSync, writeFileSync, openSync, unlinkSync } from "fs";
12
12
  import { fileURLToPath } from "url";
13
13
  import { dirname, join } from "path";
14
14
  import { homedir, tmpdir } from "os";
@@ -548,6 +548,44 @@ async function listWatchdogPids() {
548
548
  }
549
549
  }
550
550
 
551
+ /** POSIX:旧 watchdog 是否由 systemd 托管(ppid=1 且机器跑 systemd)。
552
+ * 托管场景杀旧后 systemd(Restart=always) 会自动拉起新版;若再自行 spawn 会双实例——
553
+ * 同名心跳互踢 + 业务命令双跑(Linux 适配踩坑,F-11 systemd 部署实测)。
554
+ * 仅 systemd unit ExecStart 直启时 ppid=1;终端手动跑 ppid=shell,走原 spawn 路径。 */
555
+ function isPosixSystemdManaged(oldPid) {
556
+ if (process.platform === "win32" || !oldPid) return false;
557
+ try {
558
+ if (!existsSync("/run/systemd/system")) return false;
559
+ const stat = readFileSync(`/proc/${oldPid}/stat`, "utf-8");
560
+ // comm 可含空格/括号:取最后一个 ')' 之后,第 2 个字段为 ppid(stat field 4)
561
+ const after = stat.slice(stat.lastIndexOf(")") + 1).trim();
562
+ return parseInt(after.split(/\s+/)[1], 10) === 1;
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+
568
+ /** systemd 托管升级收尾:不自行 spawn,轮询等 systemd 拉起新版(Restart=always) */
569
+ async function waitSystemdRespawn(beforePids, tag = "upgrade-proc") {
570
+ const deadline = Date.now() + 60_000;
571
+ while (Date.now() < deadline) {
572
+ const newPids = (await listWatchdogPids()).filter(
573
+ (p) => !beforePids.includes(p),
574
+ );
575
+ if (newPids.length > 0) {
576
+ console.error(
577
+ `[${tag}] systemd 已拉起新版 watchdog (pid=${newPids.join(",")}),升级完成`,
578
+ );
579
+ process.exit(0);
580
+ }
581
+ await new Promise((r) => setTimeout(r, 2000));
582
+ }
583
+ console.error(
584
+ `[${tag}] 等待 systemd 拉起新版超时(60s),请 systemctl status tt-watchdog 排查`,
585
+ );
586
+ process.exit(1);
587
+ }
588
+
551
589
  /**
552
590
  * 启动新版 watchdog(Windows 方式 2026-09-25 重写 — 214 实测教训):
553
591
  *
@@ -620,6 +658,9 @@ async function runRestartOnlyMode(argv) {
620
658
  process.exit(1);
621
659
  }
622
660
  console.error(`[restart-only] 重启 watchdog (旧 pid=${oldPid})...`);
661
+ const systemdManaged = isPosixSystemdManaged(oldPid);
662
+ // 杀旧前快照:systemd 场景用于识别「systemd 拉起的新 pid」
663
+ const beforePids = systemdManaged ? await listWatchdogPids() : [];
623
664
 
624
665
  // 杀旧:与完整模式一致(Ctrl+C 优雅 → 强杀 → 清树 → 孤儿 → 关窗)
625
666
  const procSnapshot = await snapshotAllProcesses();
@@ -643,6 +684,10 @@ async function runRestartOnlyMode(argv) {
643
684
  await killOldWatchdogWindow();
644
685
 
645
686
  // 启新(与完整模式一致:Windows 弹独立 cmd /k 窗口)
687
+ if (systemdManaged) {
688
+ console.error(`[restart-only] systemd 托管:交由 systemd(Restart=always) 拉起新版,不自行 spawn(防双实例)`);
689
+ return waitSystemdRespawn(beforePids, "restart-only");
690
+ }
646
691
  console.error(`[restart-only] 启动新版 watchdog ...`);
647
692
  const cliPath = join(__dirname, "..", "..", "cli.js");
648
693
  const args = buildWatchdogArgs({ serverUrl, clientId, intervalSec, noDaemon });
@@ -742,6 +787,10 @@ export async function runUpgradeMode(argv) {
742
787
  // 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd 窗口
743
788
  // (按窗口标题匹配 tasklist /V,绕开 WMI——部分机器 WMI 损坏时快照为空)。
744
789
  console.error(`[upgrade-proc] 通知旧 watchdog (pid=${oldPid}) 退出 ...`);
790
+ const systemdManaged = isPosixSystemdManaged(oldPid);
791
+ // 杀旧前快照:systemd 场景用于识别「systemd 拉起的新 pid」;非 win32 spawn 校验也用它
792
+ const beforePids =
793
+ process.platform === "win32" ? [] : await listWatchdogPids();
745
794
  const procSnapshot = await snapshotAllProcesses();
746
795
  const descPids = listDescendantPids(procSnapshot, oldPid, process.pid);
747
796
 
@@ -777,7 +826,15 @@ export async function runUpgradeMode(argv) {
777
826
  await killOldWatchdogWindow();
778
827
 
779
828
  // 4) 旧进程已退,启新版 watchdog
780
- // Windows:显式弹独立交互 cmd 窗口(start → cmd /k),日志直接显示在窗口,
829
+ // systemd 托管(POSIX):杀旧即完成使命,由 systemd(Restart=always) 拉新版;
830
+ // 自行 spawn 会与 systemd 拉起的实例并行 → 同名心跳互踢 + 业务双跑,防双实例
831
+ if (systemdManaged) {
832
+ console.error(
833
+ `[upgrade-proc] systemd 托管:交由 systemd 拉起新版,不自行 spawn`,
834
+ );
835
+ return waitSystemdRespawn(beforePids);
836
+ }
837
+ // Windows:显式弹独立交互 cmd 窗口(start → cmd /k),日志直接显示在窗口,
781
838
  // 与旧 cmd /k 窗口解耦(旧窗口控制台在旧 watchdog 被杀后不可复用)
782
839
  console.error(`[upgrade-proc] 启动新版 watchdog ...`);
783
840
  const cliPath = join(__dirname, "..", "..", "cli.js");
@@ -794,9 +851,6 @@ export async function runUpgradeMode(argv) {
794
851
  // Windows 存活校验改读新版 watchdog 写出的标记文件(WMI 损坏时枚举进程表失效),
795
852
  // spawn 前先删掉旧 watchdog 的残留,确保读到的是新进程写的
796
853
  if (process.platform === "win32") deleteStartedFile();
797
- // POSIX:记录启动前的 watchdog 进程集合,用于存活校验前后对比
798
- const beforePids =
799
- process.platform === "win32" ? [] : await listWatchdogPids();
800
854
  let child;
801
855
  try {
802
856
  child = spawnNewWatchdog(cliPath, args, logFd);