mslxdff 0.1.208 → 0.1.210
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.
- package/docs/cli_help_mini.md +1 -0
- package/package.json +1 -1
- package/src/cli/commands/group.js +18 -1
- package/src/cli/group-test-cli.js +66 -0
- package/src/cli/group-test.js +118 -0
- package/src/daemon.js +16 -2
- package/src/runtime/proc-info.js +11 -0
- package/src/runtime/server-lifecycle.js +15 -1
package/docs/cli_help_mini.md
CHANGED
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
| 组同步 | `-group sync` | 刷新全组成员 |
|
|
105
105
|
| 组离开单 | `-group leave <name>` | 离开单组 |
|
|
106
106
|
| 组列表 | `-group list` | 列组+成员+健康/序号 |
|
|
107
|
+
| 组员链路测试 | `-group test <target\|seq> [--group <名>] [--model <id>] [--say <词>] [--json]` | 验「本机 → 组长 → 某组员(含 relay)→ 上游」整条路,**按阶段指出断在哪一跳**;target 可填 url / `relay://id` / `-group list` 序号 |
|
|
107
108
|
| 组踢人 | `-group remove <seq>` | 仅 leader 按序号踢人 |
|
|
108
109
|
| 全部离开 | `-leavegroup` / `--leavegroup` | 离开所有成员组 |
|
|
109
110
|
| 解散组 | `-delgroup <name>` / `--delgroup` | 仅 leader 解散 |
|
package/package.json
CHANGED
|
@@ -110,6 +110,23 @@ export async function handleGroupCommand(args) {
|
|
|
110
110
|
}
|
|
111
111
|
console.log(`\njoined groups (${joinedList.length}):`);
|
|
112
112
|
for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
|
|
113
|
+
} else if (action === "test") {
|
|
114
|
+
// -group test <target> [--group <名>] [--model <id>] [--say <词>] [--json]
|
|
115
|
+
// target 可以是 url / relay://id / 或 -group list 里的序号(数字)。
|
|
116
|
+
const { resolveTargetArg, renderGroupTest } = await import("../group-test-cli.js");
|
|
117
|
+
const target = await resolveTargetArg(a, rest);
|
|
118
|
+
if (!target) {
|
|
119
|
+
console.error("usage: mslxdff -group test <target|seq> [--group <name>] [--model <id>] [--say <text>]");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const model = argValue(args, "--model", "-m") || undefined;
|
|
123
|
+
const say = argValue(args, "--say", "-s") || undefined;
|
|
124
|
+
const groupArg = argValue(args, "--group", "-g") || undefined;
|
|
125
|
+
const asJson = args.includes("--json");
|
|
126
|
+
const { runGroupLinkTest } = await import("../group-test.js");
|
|
127
|
+
const r = await runGroupLinkTest({ group: groupArg || "", target, ...(model ? { model } : {}), ...(say ? { prompt: say } : {}) });
|
|
128
|
+
renderGroupTest(r, { json: asJson });
|
|
129
|
+
process.exit(r.status === "ok" ? 0 : 1);
|
|
113
130
|
} else if (action === "remove" && a) {
|
|
114
131
|
const seq = Number(a);
|
|
115
132
|
if (!Number.isInteger(seq) || seq < 1) {
|
|
@@ -138,7 +155,7 @@ export async function handleGroupCommand(args) {
|
|
|
138
155
|
process.exit(1);
|
|
139
156
|
}
|
|
140
157
|
} else {
|
|
141
|
-
console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
|
|
158
|
+
console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group test <target|seq> | -group remove <seq> | -creategroup <name>");
|
|
142
159
|
}
|
|
143
160
|
process.exit(0);
|
|
144
161
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// `-group test` 的 CLI 适配层:把 -group list 的序号解析成 target,并把链路测试结果渲染成四态人话。
|
|
2
|
+
import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
|
|
3
|
+
import { refreshGroupMembers } from "../groups.js";
|
|
4
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
5
|
+
import { resolveLeader } from "./group-test.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 把用户给的 target 归一:数字 = -group list 里的序号;否则原样(url / relay://id)。
|
|
9
|
+
* 与 -group list 的编号口径一致(跳过 leader、broadband/static 都算)。
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveTargetArg(a, rest = []) {
|
|
12
|
+
const raw = String(a ?? "").trim();
|
|
13
|
+
if (!raw) return "";
|
|
14
|
+
if (!/^\d+$/.test(raw)) return raw; // url 或 relay://id
|
|
15
|
+
const seq = Number(raw);
|
|
16
|
+
const joined = loadGroupsJoined();
|
|
17
|
+
const g = joined.find((x) => x?.leaderUrl) || joined[0];
|
|
18
|
+
if (!g) return "";
|
|
19
|
+
let members;
|
|
20
|
+
if (g.leaderUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const { token } = await loadToken();
|
|
23
|
+
members = await refreshGroupMembers(g.name, {
|
|
24
|
+
leaderUrl: g.leaderUrl, memberName: g.memberName, url: g.myUrl, token,
|
|
25
|
+
fetchImpl: (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(4000) }),
|
|
26
|
+
});
|
|
27
|
+
} catch { return ""; }
|
|
28
|
+
} else {
|
|
29
|
+
return ""; // 本机是组长时,序号口径见 -group list(此处不展开)
|
|
30
|
+
}
|
|
31
|
+
const entries = Object.entries(members || {}).filter(([id]) => id !== "leader");
|
|
32
|
+
const hit = entries[seq - 1];
|
|
33
|
+
return hit ? (hit[1]?.url || hit[0]) : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 结果 → 人话。`json` 时直接吐结构化结果(脚本用)。 */
|
|
37
|
+
export function renderGroupTest(r, { json = false } = {}) {
|
|
38
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
39
|
+
console.log("=".repeat(62));
|
|
40
|
+
console.log(` 组员链路测试:本机 → 组长 → ${r.target}`);
|
|
41
|
+
console.log("=".repeat(62));
|
|
42
|
+
console.log(` 组长 ${r.leaderUrl || "?"}|组 ${r.group || "?"}${r.ms !== undefined ? `|耗时 ${r.ms}ms` : ""}`);
|
|
43
|
+
console.log("");
|
|
44
|
+
if (r.status === "ok") {
|
|
45
|
+
console.log(` ✅ 通了!经组长转发到该组员,上游返回:`);
|
|
46
|
+
console.log(` "${String(r.content || "").slice(0, 200) || "(空正文)"}"`);
|
|
47
|
+
process.exitCode = 0;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const stageZh = {
|
|
51
|
+
usage: "参数错误",
|
|
52
|
+
"resolve-leader": "定位组长失败",
|
|
53
|
+
"resolve-group": "确定组名失败",
|
|
54
|
+
"forward-fetch": "连不上组长",
|
|
55
|
+
auth: "鉴权被拒",
|
|
56
|
+
"target-missing": "目标不在组内",
|
|
57
|
+
"max-hops": "转发跳数超限",
|
|
58
|
+
"relay-timeout": "组长入了队但成员没回",
|
|
59
|
+
"forward-failed": "组长转发失败",
|
|
60
|
+
upstream: "成员打上游失败",
|
|
61
|
+
}[r.stage] || r.stage;
|
|
62
|
+
console.log(` ❌ 没通 — 断在:**${stageZh}**${r.httpStatus ? `(HTTP ${r.httpStatus})` : ""}`);
|
|
63
|
+
if (r.error) console.log(` 原因:${String(r.error).slice(0, 300)}`);
|
|
64
|
+
if (r.hint) console.log(` 下一步:${r.hint}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// 组员链路测试:验证「本机 → 组长 → 某组员(含 relay 宽带成员)→ 上游 → 回数据」整条路。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:这条链路跨 3 台机器 + 1 道鉴权,坏了只会看到一句 `502 fetch failed`,
|
|
4
|
+
// 完全无法定位是哪一跳断的。本模块**按阶段**记录,把断点直接指出来:
|
|
5
|
+
// ① 定位本机组/组长 → ② 取组长 token(调组长 forward 要**组长的** token,见下)→
|
|
6
|
+
// ③ POST 组长 /v1/groups/relay/forward → ④ 判读回包(200 拿内容 / 401 鉴权 / 404 成员不存在 / 502·504 转发失败)
|
|
7
|
+
//
|
|
8
|
+
// ⚠ 鉴权事实(2026-10-19 实测):`authorized()` 比的是**接收方自己的 token**,
|
|
9
|
+
// 而 `/v1/groups/relay/forward` 在路由层 requiresAuth=true ⇒ 要**组长 token**。
|
|
10
|
+
// 普通组员只有自己的 token ⇒ 调不动组长 forward(这就是"组员经组长"从没通的原因)。
|
|
11
|
+
// 本命令调用时用组长 token;若拿不到组长 token(如你只是组员且未持有),会明确报出这一步。
|
|
12
|
+
import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
|
|
13
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
14
|
+
|
|
15
|
+
/** 默认探测模型与提示词。 */
|
|
16
|
+
export const DEFAULT_TEST_MODEL = "deepseek-v4.1-flash-free";
|
|
17
|
+
export const DEFAULT_TEST_PROMPT = "hi";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 找一个可用的组长 token:优先 state.peers 里名为 leader 的记录,其次 groupsJoined 里非本机 url 的 peer。
|
|
21
|
+
* @returns {{ leaderUrl: string, leaderToken: string, group: string, source: string } | null}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveLeader({ peers = [], joined = [] } = {}) {
|
|
24
|
+
const byName = (peers || []).find((p) => String(p?.name || "") === "leader" && p?.url);
|
|
25
|
+
if (byName) {
|
|
26
|
+
const g = joined.find((x) => x?.leaderUrl === byName.url);
|
|
27
|
+
return { leaderUrl: byName.url, leaderToken: String(byName.token || ""), group: g?.name || "", source: "peers[leader]" };
|
|
28
|
+
}
|
|
29
|
+
// 兜底:用 groupsJoined 的 leaderUrl 去 peers 里找同 url 的记录取 token
|
|
30
|
+
const g = (joined || []).find((x) => x?.leaderUrl);
|
|
31
|
+
if (g) {
|
|
32
|
+
const p = (peers || []).find((x) => x?.url === g.leaderUrl);
|
|
33
|
+
return { leaderUrl: g.leaderUrl, leaderToken: String(p?.token || ""), group: g.name, source: "groupsJoined.leaderUrl" };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 跑一次链路测试。返回结构化结果(不打印,由 CLI 渲染)。
|
|
40
|
+
*
|
|
41
|
+
* @param {{ group?: string, target: string, model?: string, prompt?: string, timeoutMs?: number,
|
|
42
|
+
* deps?: { peers?, joined?, myToken?, fetchImpl? } }} opts
|
|
43
|
+
* @returns {Promise<{ status: "ok"|"error", stage: string, httpStatus?: number, content?: string,
|
|
44
|
+
* reply?: any, leaderUrl?: string, group?: string, target: string, ms?: number,
|
|
45
|
+
* error?: string, hint?: string }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function runGroupLinkTest({
|
|
48
|
+
group = "",
|
|
49
|
+
target = "",
|
|
50
|
+
model = DEFAULT_TEST_MODEL,
|
|
51
|
+
prompt = DEFAULT_TEST_PROMPT,
|
|
52
|
+
timeoutMs = 45_000,
|
|
53
|
+
deps = {},
|
|
54
|
+
} = {}) {
|
|
55
|
+
if (!target) return { status: "error", stage: "usage", target, error: "必须给目标组员(url、relay://id 或 -group list 里的序号对应项)" };
|
|
56
|
+
|
|
57
|
+
const peers = deps.peers ?? loadPeers();
|
|
58
|
+
const joined = deps.joined ?? loadGroupsJoined();
|
|
59
|
+
const myToken = deps.myToken ?? (await loadToken()).token;
|
|
60
|
+
const fetchImpl = deps.fetchImpl ?? compatFetch;
|
|
61
|
+
|
|
62
|
+
const lead = resolveLeader({ peers, joined });
|
|
63
|
+
if (!lead) return { status: "error", stage: "resolve-leader", target, error: "本机没有可用的组长记录(未加入任何组?)", hint: "先 mslxdff -addtogroup <组长host> <组名>" };
|
|
64
|
+
const groupName = group || lead.group;
|
|
65
|
+
if (!groupName) return { status: "error", stage: "resolve-group", target, error: "无法确定组名(state 里 peers/groupsJoined 缺 group)", hint: "用 -group 显式指定组名" };
|
|
66
|
+
|
|
67
|
+
const reqId = `gt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
68
|
+
const body = { group: groupName, target, reqId, body: { model, messages: [{ role: "user", content: prompt }], stream: false } };
|
|
69
|
+
|
|
70
|
+
const t0 = Date.now();
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetchImpl(`${lead.leaderUrl}/v1/groups/relay/forward`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${lead.leaderToken}` },
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
signal: timeoutSignal(timeoutMs),
|
|
78
|
+
});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
const msg = String(e?.message || e);
|
|
81
|
+
return { status: "error", stage: "forward-fetch", target, leaderUrl: lead.leaderUrl, group: groupName, ms: Date.now() - t0,
|
|
82
|
+
error: `连组长都打不通:${msg}`, hint: "组长地址/网络不通;先 mslxdff -group list 看组长健康" };
|
|
83
|
+
}
|
|
84
|
+
const ms = Date.now() - t0;
|
|
85
|
+
const txt = await res.text().catch(() => "");
|
|
86
|
+
|
|
87
|
+
// 按阶段归类失败 —— 这是本命令存在的意义:一眼看出断在哪一跳
|
|
88
|
+
if (res.status === 401) {
|
|
89
|
+
return { status: "error", stage: "auth", httpStatus: 401, target, leaderUrl: lead.leaderUrl, group: groupName, ms,
|
|
90
|
+
error: "组长拒了鉴权(401):调 /v1/groups/relay/forward 需要**组长的** token,本机没有",
|
|
91
|
+
hint: "这是已知缺陷:组员中继链路的鉴权断了。修复 = 组长侧 forward 应接受成员 token(见 -group test 的 PR)" };
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 404) {
|
|
94
|
+
return { status: "error", stage: "target-missing", httpStatus: 404, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "该目标不在组内(名称/url 对不上);用 -group list 核对" };
|
|
95
|
+
}
|
|
96
|
+
if (res.status === 429) {
|
|
97
|
+
return { status: "error", stage: "max-hops", httpStatus: 429, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "转发跳数超限(有环)" };
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 504) {
|
|
100
|
+
return { status: "error", stage: "relay-timeout", httpStatus: 504, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "组长入了队但目标成员没在 30s 内回结果(成员不在线/不通)" };
|
|
101
|
+
}
|
|
102
|
+
if (res.status !== 200) {
|
|
103
|
+
const isFetchFail = /fetch failed/i.test(txt);
|
|
104
|
+
const isRelay = String(target).startsWith("relay://");
|
|
105
|
+
return { status: "error", stage: "forward-failed", httpStatus: res.status, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 300),
|
|
106
|
+
hint: isFetchFail && isRelay
|
|
107
|
+
? "对 relay 成员,502 fetch failed 通常是**该成员自己的上游**打不通(组长只负责转发,不碰上游)。判据:同组其他 relay 成员若正常,即证是这一台的网络/上游问题,不是组长。也可能是该成员离线。"
|
|
108
|
+
: isFetchFail ? "组长直连该静态成员失败(它离线或端口不通)" : "" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let reply = null;
|
|
112
|
+
try { reply = JSON.parse(txt); } catch { reply = null; }
|
|
113
|
+
const content = reply?.choices?.[0]?.message?.content ?? reply?.content ?? "";
|
|
114
|
+
if (reply && (reply.error || reply.object === "error")) {
|
|
115
|
+
return { status: "error", stage: "upstream", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, reply, error: String(reply.error?.message || reply.error || "").slice(0, 300), hint: "组长转到了成员,但成员打上游失败(该成员自己的上游/额度问题)" };
|
|
116
|
+
}
|
|
117
|
+
return { status: "ok", stage: "done", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, content: String(content || ""), reply };
|
|
118
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -102,12 +102,26 @@ export function isPidAlive(pid) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function stopDaemon({ reason = "stop", kill = process.kill, taskkill = spawnSync } = {}) {
|
|
105
|
+
export function stopDaemon({ reason = "stop", kill = process.kill, taskkill = spawnSync, isAlive = isPidAlive } = {}) {
|
|
106
106
|
const pid = readPid();
|
|
107
107
|
if (!pid) return { stopped: false, reason: "no pid file" };
|
|
108
|
+
// 自杀护栏(最高优先级):pid 文件里写着**本进程**时,杀它就是杀自己。
|
|
109
|
+
// 这不是假想情形 —— daemon 自己 `writePid(process.pid)`(server-lifecycle.js),而 `enableAutostart()`
|
|
110
|
+
// 会在 systemd 托管下被 daemon 自己调起(server-lifecycle 的 autostart 自检块):systemd **系统** unit
|
|
111
|
+
// 的 daemon 去查 **user** unit 必然判「未启用」⇒ 走进 enableAutostart ⇒ stopDaemon ⇒ SIGTERM 自己
|
|
112
|
+
// ⇒ 优雅关停 exit 0 ⇒ systemd Restart=always 拉起 ⇒ 每 2.5 秒一轮的无限重启
|
|
113
|
+
//(真机 2026-10-19:0.1.209 + 系统 unit,每进程活 2~3s 被 SIGTERM;0.1.160 不炸只因当时
|
|
114
|
+
// `enableAutostart` 里 import 路径写错、这段从未执行过,2026-10-17 c7bf8af 修正路径后暴露)。
|
|
115
|
+
if (pid === process.pid) {
|
|
116
|
+
try {
|
|
117
|
+
appendFileSync(logFile(), `[lifecycle] stopDaemon refused: pid file points at self (pid=${pid}, reason=${reason})\n`);
|
|
118
|
+
} catch {}
|
|
119
|
+
return { stopped: false, pid, reason: "self — pid 文件写的就是本进程,拒绝自杀" };
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
// 杀者牌(验尸官的另一半证据):动手前写、自带死者身份。只给在世者留牌——给尸体留牌
|
|
109
123
|
// 会让下次验尸把"早已静默死亡"误判成"被本次杀死"。
|
|
110
|
-
const victimAlive =
|
|
124
|
+
const victimAlive = isAlive(pid);
|
|
111
125
|
// 杀者留痕(写 daemon.log):Windows 的 SIGTERM 是 TerminateProcess 强杀,被杀的 daemon
|
|
112
126
|
// 在 JS 层收不到任何事件(无法自记),故由"杀者"记录调用方 pid——
|
|
113
127
|
// 没有这行 = 非我方所杀(外部 taskkill/任务管理器/崩溃),配合最后一条 heartbeat 定位死亡时刻。
|
package/src/runtime/proc-info.js
CHANGED
|
@@ -92,6 +92,17 @@ export function isDaemonArgv(argv) {
|
|
|
92
92
|
return list.includes("--daemon") && list.some((a) => /(^|[\\/])mslxdff(\.js)?$/i.test(String(a)));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 是否跑在 systemd 托管下(system 或 user unit 都算)。
|
|
97
|
+
* 判据:systemd 给被管服务注入 `INVOCATION_ID`(每个 invocation 唯一)与 `JOURNAL_STREAM`(stdout 连接信息)。
|
|
98
|
+
* 为什么要问:托管时 supervisor 已保证单实例与重启策略,daemon 再自己「杀旧进程/杀端口占用者」是职责重叠,
|
|
99
|
+
* 会与 systemd 的拉起时序打架成死循环(真机 2026-10-19:0.1.209 + 系统 unit 每 2.5s 自杀一轮)。
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function underSystemd(env = process.env) {
|
|
103
|
+
return Boolean(env.INVOCATION_ID || env.JOURNAL_STREAM);
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
/** 「已运行 6h12m」这种人话时长(取证日志用)。 */
|
|
96
107
|
export function fmtAlive(sec) {
|
|
97
108
|
if (!Number.isFinite(sec)) return "运行时长未知";
|
|
@@ -5,6 +5,7 @@ import { runHook } from "../plugins.js";
|
|
|
5
5
|
import { effectiveHost, maxHopsValue } from "../cli/policy.js";
|
|
6
6
|
import { fmtEvent } from "../cli/format.js";
|
|
7
7
|
import { writePid } from "../daemon.js";
|
|
8
|
+
import { underSystemd } from "./proc-info.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* 服务生命周期 — router 装配 → server 创建/ready-EADDRINUSE 自愈 →
|
|
@@ -66,6 +67,13 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
66
67
|
const msg = String(err?.message || err);
|
|
67
68
|
const code = err?.code || "";
|
|
68
69
|
if (code === "EADDRINUSE" || msg.includes("EADDRINUSE")) {
|
|
70
|
+
// systemd 托管下不许杀端口占用者:supervisor 保证单实例,去杀「占用者」是越权,且会与
|
|
71
|
+
// systemd 的拉起时序互杀成循环(报告的另一半)。非零退出,让 systemd 按 Restart/RestartSec
|
|
72
|
+
// 退避重试——这才是托管语义下正确的收口。
|
|
73
|
+
if (underSystemd()) {
|
|
74
|
+
console.error(`[lifecycle] EADDRINUSE on port ${resolvePort()} under systemd — exit(1), let systemd backoff-retry(不杀任何进程)`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
69
77
|
console.log(`[lifecycle] EADDRINUSE on port ${resolvePort()} — freeing stale holder and retrying...`);
|
|
70
78
|
try {
|
|
71
79
|
const { execFile } = await import("node:child_process");
|
|
@@ -166,7 +174,13 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
166
174
|
} catch {}
|
|
167
175
|
|
|
168
176
|
// best-effort: ensure autostart on Linux (so daemon survives reboot/SSH disconnect without manual cmd)
|
|
169
|
-
|
|
177
|
+
// **systemd 托管下必须跳过**(2026-10-19 真机无限重启的根因):
|
|
178
|
+
// - 托管时 supervisor 已经保证单实例+开机自启,这里再 enable 一次多余;
|
|
179
|
+
// - 本块查的是 **user** unit(`~/.config/systemd/user/mslxdff.service`),而 systemd **系统**
|
|
180
|
+
// unit 托管的 daemon 查它必然得 enabled=false ⇒ 走 enableAutostart() ⇒ linuxEnable() ⇒ stopDaemon()。
|
|
181
|
+
// 那条路径里 pid 文件写的就是**本进程**,过去等于每 2.5s 自杀一次(见 daemon.js 的自杀护栏)。
|
|
182
|
+
// systemd 会给被管进程注入 INVOCATION_ID / JOURNAL_STREAM,判据可靠、无需用户配置。
|
|
183
|
+
if (process.platform === "linux" && !process.env.MSLXDFF_NO_AUTOSTART && !underSystemd()) {
|
|
170
184
|
setTimeout(async () => {
|
|
171
185
|
try {
|
|
172
186
|
const { getAutostartStatus, enableAutostart } = await import("../autostart.js");
|