@shendeguize/remote-dsh-center 0.4.0
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/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
package/src/launcher.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 拉起/停止/重启的远端侧执行器(12 §1.3、§3、§5)+ 编排(02 §3.2/§3.5,见文件后半)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DshError } from './lib/errors.js';
|
|
6
|
+
import { logEvent } from './lib/bus.js';
|
|
7
|
+
import {
|
|
8
|
+
PROTO_TIMING,
|
|
9
|
+
buildLaunchPollScript,
|
|
10
|
+
buildLaunchScript,
|
|
11
|
+
buildLogTailScript,
|
|
12
|
+
buildStopScript,
|
|
13
|
+
buildVerifyScript,
|
|
14
|
+
kvOne,
|
|
15
|
+
parseLaunchUrl,
|
|
16
|
+
parseProtoOutput,
|
|
17
|
+
} from './lib/proto.js';
|
|
18
|
+
import {
|
|
19
|
+
execFailure, hostQueue, localExec, noteTruncation, sshExec,
|
|
20
|
+
} from './lib/ssh.js';
|
|
21
|
+
import { ensureLocalPort } from './ports.js';
|
|
22
|
+
import { syncPatches } from './patchsync.js';
|
|
23
|
+
import * as store from './store.js';
|
|
24
|
+
import * as tunnel from './tunnel.js';
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
27
|
+
|
|
28
|
+
/** 注入点:测试用假时钟替换等待,避免真等 9 秒。 */
|
|
29
|
+
let waitFn = sleep;
|
|
30
|
+
export function _setWait(fn) {
|
|
31
|
+
waitFn = typeof fn === 'function' ? fn : sleep;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 注入点:STOP timeout 回归用例缩短本机命令预算,生产默认值不变。 */
|
|
35
|
+
let stopTimeoutMs = PROTO_TIMING.onceTimeoutMs;
|
|
36
|
+
export function _setStopTimeout(ms) {
|
|
37
|
+
stopTimeoutMs = Number.isFinite(ms) && ms > 0 ? ms : PROTO_TIMING.onceTimeoutMs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 显式参数优先;未传时只认当前 HostView.local,绝不按主机名猜运输类型。 */
|
|
41
|
+
function isLocalHost(host, local) {
|
|
42
|
+
if (typeof local === 'boolean') return local;
|
|
43
|
+
return store.getHostView(host)?.local === true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 同一份协议脚本只在运输执行器处二选一。 */
|
|
47
|
+
function execHost(host, command, { local, signal, timeoutMs }) {
|
|
48
|
+
return local
|
|
49
|
+
? localExec(command, { signal, timeoutMs })
|
|
50
|
+
: sshExec(host, command, { signal, timeoutMs });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── RMT-05 复核与停止 ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {{alive:boolean, argsRaw:string|null, listen:'yes'|'no'|'unknown',
|
|
57
|
+
* fingerprintMatch:boolean|null, cwd:string|null}} VerifyResult
|
|
58
|
+
* cwd = 远端进程实际工作目录,仅供展示;unknown/缺失均归 null,永不作 kill 判据。
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* VERIFY 三项校验(12 §1.3)。指纹全等在 manager 侧判定:
|
|
63
|
+
* 同一台机器、同一 ps 实现、同一 $(…) 尾随换行剥除路径 → 逐字全等成立。
|
|
64
|
+
* @param {{pid:number, port:number, fingerprint?:string|null}} p
|
|
65
|
+
* @returns {Promise<VerifyResult>}
|
|
66
|
+
*/
|
|
67
|
+
export async function verifyRemote(host, { pid, port, fingerprint = null }, { signal, local } = {}) {
|
|
68
|
+
const useLocal = isLocalHost(host, local);
|
|
69
|
+
const res = await execHost(host, buildVerifyScript({ pid, port }), { local: useLocal, signal });
|
|
70
|
+
const err = execFailure(host, '远端复核', res);
|
|
71
|
+
if (err) throw err;
|
|
72
|
+
|
|
73
|
+
const out = parseProtoOutput(res.stdout, { requireDone: 'VERIFY_DONE' });
|
|
74
|
+
const alive = kvOne(out, 'ALIVE') === 'yes';
|
|
75
|
+
const argsRaw = alive ? (out.blocks.ARGS ?? null) : null;
|
|
76
|
+
const listen = /** @type {'yes'|'no'|'unknown'} */ (kvOne(out, 'LISTEN', 'unknown'));
|
|
77
|
+
const cwdRaw = kvOne(out, 'CWD');
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
alive,
|
|
81
|
+
argsRaw,
|
|
82
|
+
listen,
|
|
83
|
+
fingerprintMatch: fingerprint === null || argsRaw === null ? null : argsRaw === fingerprint,
|
|
84
|
+
cwd: cwdRaw === null || cwdRaw === 'unknown' || cwdRaw === '' ? null : cwdRaw,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 指纹采集(12 §5.2):用 ps 的**实测输出**,不是 manager 拼装的命令行——
|
|
90
|
+
* nohup/env 均 exec 链传递,最终 args 是 `dsh web …` 形态;`--port 0` 拉起的进程
|
|
91
|
+
* args 里就是 `0`(这正是「命令行含记录端口」不可实现的原因,契约疑议 1)。
|
|
92
|
+
*
|
|
93
|
+
* 顺带回带 cwd(同一次 VERIFY 往返,不多花一趟 ssh)——纯展示用。
|
|
94
|
+
* @returns {Promise<{fingerprint:string, cwd:string|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function captureFingerprint(host, { pid, port }, { signal, local } = {}) {
|
|
97
|
+
const v = await verifyRemote(host, { pid, port }, { signal, local });
|
|
98
|
+
if (!v.alive || !v.argsRaw) {
|
|
99
|
+
throw new DshError('LAUNCH_FAILED', `拉起后复核发现进程已消失(pid=${pid})`, { host });
|
|
100
|
+
}
|
|
101
|
+
return { fingerprint: v.argsRaw, cwd: v.cwd };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {{killed:'term'|'force'|'already-dead'|'no', reason:string|null, actualArgs:string|null}} StopResult
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* STOP 四结果分支(12 §1.3)。`no` = 指纹不符:**manager 不清 state、抛 KILL_REFUSED、
|
|
110
|
+
* 附双方指纹供人工裁决**(03 §4.2 原则)。
|
|
111
|
+
* @returns {Promise<StopResult>}
|
|
112
|
+
*/
|
|
113
|
+
export async function stopRemote(host, { pid, fingerprint }, { signal, local, timeoutMs } = {}) {
|
|
114
|
+
const useLocal = isLocalHost(host, local);
|
|
115
|
+
const res = await execHost(
|
|
116
|
+
host,
|
|
117
|
+
buildStopScript({ pid, fingerprint }),
|
|
118
|
+
{ local: useLocal, signal, timeoutMs },
|
|
119
|
+
);
|
|
120
|
+
const err = execFailure(host, '远端停止', res);
|
|
121
|
+
if (err) throw err;
|
|
122
|
+
|
|
123
|
+
const out = parseProtoOutput(res.stdout, { requireDone: 'STOP_DONE' });
|
|
124
|
+
const killed = kvOne(out, 'KILLED');
|
|
125
|
+
if (!['term', 'force', 'already-dead', 'no'].includes(killed)) {
|
|
126
|
+
throw new DshError('PROTO_PARSE', `停止协议返回未知结果:${killed}`, { host, detail: res.stdout });
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
killed,
|
|
130
|
+
reason: kvOne(out, 'REASON'),
|
|
131
|
+
actualArgs: out.blocks.ARGS ?? null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 指纹不符时的统一拒杀错误(含双方指纹)。 */
|
|
136
|
+
export function killRefused(host, { pid, expected, actual }, { local } = {}) {
|
|
137
|
+
const subject = isLocalHost(host, local) ? '本机' : '远端';
|
|
138
|
+
return new DshError(
|
|
139
|
+
'KILL_REFUSED',
|
|
140
|
+
`拒绝关停 pid=${pid}:${subject}命令行与记录指纹不符(可能已被 PID 复用或为手动实例)`,
|
|
141
|
+
{
|
|
142
|
+
host,
|
|
143
|
+
detail: `记录指纹:\n${expected}\n\n${subject}实测:\n${actual ?? '(空)'}`,
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── RMT-08 日志获取 ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** logName 取自 state.web.log(manager 自造名,仍过 [name] 校验兜底)。 */
|
|
151
|
+
export async function tailRemoteLog(host, { logName, lines = 200 }, { signal, local } = {}) {
|
|
152
|
+
const useLocal = isLocalHost(host, local);
|
|
153
|
+
const res = await execHost(host, buildLogTailScript({ logName, lines }), { local: useLocal, signal });
|
|
154
|
+
const err = execFailure(host, '取远端日志', res);
|
|
155
|
+
if (err) throw err;
|
|
156
|
+
// 行数在远端限了(tail -n),字节数没限:一条带 \r 的进度条就是超长的单行,
|
|
157
|
+
// 撞上封顶只能收到末尾那截。得说一声,否则看的人以为日志本来就长这样(issue #92)。
|
|
158
|
+
return noteTruncation(res.stdout, res.stdoutDropped, useLocal ? '本机' : '远端') ?? '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── RMT-06 拉起协议状态机(12 §3 的 S0–S5) ─────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** 降级拉起的日志名 token:与 pid 同等唯一,且重定向目标必须在拿到 PID 之前确定。 */
|
|
164
|
+
export function launchToken() {
|
|
165
|
+
return Date.now().toString(36); // 墙钟:当唯一 token 用,不参与任何流逝判断
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function fixedLogName(port) {
|
|
169
|
+
return `web-${port}.log`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function autoLogName(token = launchToken()) {
|
|
173
|
+
return `web-auto-${token}.log`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 前置语句(mkdir / cd)的失败:脚本以非零码退出,若先过 execFailure 会被归成
|
|
178
|
+
* SSH_UNREACHABLE——「远端连不上」和「目录进不去」对用户是两件事,故先认标记。
|
|
179
|
+
* 用正则而非 parseProtoOutput:此时 stdout 可能只有半截,解析器不该在这里抛。
|
|
180
|
+
* @returns {DshError|null}
|
|
181
|
+
*/
|
|
182
|
+
function preludeFailure(host, stdout, { local = false } = {}) {
|
|
183
|
+
const marker = /^ERR=(mkdir|workdir)$/m.exec(stdout ?? '');
|
|
184
|
+
if (!marker) return null;
|
|
185
|
+
const subject = local ? '本机' : '远端';
|
|
186
|
+
// message 不带主机名:调用方 failure() 会包成「拉起失败(host):<message>」
|
|
187
|
+
if (marker[1] === 'workdir') {
|
|
188
|
+
const wd = /^WD=(.*)$/m.exec(stdout)?.[1] ?? '(未回显)';
|
|
189
|
+
return new DshError('LAUNCH_FAILED', `${subject}工作目录不存在或不可进入`, {
|
|
190
|
+
host,
|
|
191
|
+
detail: `目标目录:${wd}\n改主机详情里的「启动目录」,或先在${subject}建好该目录。`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return new DshError('LAUNCH_FAILED', `${subject}无法创建落地目录 .dsh_center_remote`, {
|
|
195
|
+
host,
|
|
196
|
+
detail: stdout,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** S1 LAUNCH:拿 PID。 */
|
|
201
|
+
async function launchOnce(host, spec, { signal, local }) {
|
|
202
|
+
const res = await execHost(host, buildLaunchScript(spec), { local, signal });
|
|
203
|
+
const prelude = preludeFailure(host, res.stdout, { local });
|
|
204
|
+
if (prelude) throw prelude;
|
|
205
|
+
const err = execFailure(host, '远端拉起', res);
|
|
206
|
+
if (err) throw err;
|
|
207
|
+
|
|
208
|
+
const out = parseProtoOutput(res.stdout);
|
|
209
|
+
const pidRaw = kvOne(out, 'PID');
|
|
210
|
+
const pid = Number(pidRaw);
|
|
211
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
212
|
+
throw new DshError('LAUNCH_FAILED', '远端拉起未返回 PID', { host, detail: res.stdout });
|
|
213
|
+
}
|
|
214
|
+
return pid;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* S2 POLL 环:首拍 T+1s,此后每 2s 一拍,最多 5 拍。
|
|
219
|
+
* @returns {Promise<{outcome:'url'|'bind-err'|'dead'|'timeout', actualPort?:number}>}
|
|
220
|
+
*/
|
|
221
|
+
async function pollLaunch(host, { logName, pid }, { signal, local }) {
|
|
222
|
+
await waitFn(PROTO_TIMING.pollFirstDelayMs);
|
|
223
|
+
for (let attempt = 0; attempt < PROTO_TIMING.pollMaxAttempts; attempt += 1) {
|
|
224
|
+
if (attempt > 0) await waitFn(PROTO_TIMING.pollIntervalMs);
|
|
225
|
+
|
|
226
|
+
const res = await execHost(
|
|
227
|
+
host,
|
|
228
|
+
buildLaunchPollScript({ logName, pid }),
|
|
229
|
+
{ local, signal },
|
|
230
|
+
);
|
|
231
|
+
const err = execFailure(host, '拉起轮询', res);
|
|
232
|
+
if (err) throw err;
|
|
233
|
+
const out = parseProtoOutput(res.stdout, { requireDone: 'POLL_DONE' });
|
|
234
|
+
|
|
235
|
+
const url = kvOne(out, 'URL');
|
|
236
|
+
if (url) {
|
|
237
|
+
const actualPort = parseLaunchUrl(url);
|
|
238
|
+
if (actualPort === null) {
|
|
239
|
+
throw new DshError('PROTO_PARSE', `无法从 dsh 输出解析端口:${url}`, { host, detail: res.stdout });
|
|
240
|
+
}
|
|
241
|
+
return { outcome: 'url', actualPort };
|
|
242
|
+
}
|
|
243
|
+
// BIND_ERR 优先于 ALIVE:端口被占时进程可能还没退出
|
|
244
|
+
if (kvOne(out, 'BIND_ERR') === 'yes') return { outcome: 'bind-err' };
|
|
245
|
+
if (kvOne(out, 'ALIVE') === 'no') return { outcome: 'dead' };
|
|
246
|
+
}
|
|
247
|
+
return { outcome: 'timeout' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function tailQuiet(host, logName, signal, local) {
|
|
251
|
+
try {
|
|
252
|
+
return await tailRemoteLog(
|
|
253
|
+
host,
|
|
254
|
+
{ logName, lines: PROTO_TIMING.logTailLines },
|
|
255
|
+
{ local, signal },
|
|
256
|
+
);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return `(取日志失败:${err.message})`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* S0–S5 全路径。S0(patch 同步)由调用方在此之前完成。
|
|
264
|
+
*
|
|
265
|
+
* @param {string} host
|
|
266
|
+
* @param {{port:number, env?:Record<string,string>, extraArgs?:string[], patchRemoteNames?:string[],
|
|
267
|
+
* workdir?:string|null}} spec workdir=null 表示不注入 cd(远端 $HOME 启动)
|
|
268
|
+
* @returns {Promise<{pid:number, actualPort:number, logName:string, fingerprint:string, cwd:string|null}>}
|
|
269
|
+
* @throws {DshError} LAUNCH_FAILED(detail 含一到两份日志尾)
|
|
270
|
+
*/
|
|
271
|
+
export async function runLaunchSequence(host, spec, { signal, local } = {}) {
|
|
272
|
+
const {
|
|
273
|
+
port, env = {}, extraArgs = [], patchRemoteNames = [], workdir = null,
|
|
274
|
+
} = spec;
|
|
275
|
+
const useLocal = isLocalHost(host, local);
|
|
276
|
+
const attempts = [];
|
|
277
|
+
let usePort = port;
|
|
278
|
+
let logName = fixedLogName(port);
|
|
279
|
+
let fallbackUsed = false;
|
|
280
|
+
|
|
281
|
+
for (;;) {
|
|
282
|
+
const launchSpec = {
|
|
283
|
+
logName, port: usePort, env, extraArgs, patchRemoteNames, workdir,
|
|
284
|
+
};
|
|
285
|
+
// S1
|
|
286
|
+
let pid;
|
|
287
|
+
try {
|
|
288
|
+
pid = await launchOnce(host, launchSpec, { local: useLocal, signal });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
attempts.push({
|
|
291
|
+
logName,
|
|
292
|
+
note: err.message,
|
|
293
|
+
log: await tailQuiet(host, logName, signal, useLocal),
|
|
294
|
+
});
|
|
295
|
+
throw failure(host, attempts, err);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// S2
|
|
299
|
+
let poll;
|
|
300
|
+
try {
|
|
301
|
+
poll = await pollLaunch(host, { logName, pid }, { local: useLocal, signal });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await orphanCleanup(host, pid, signal, useLocal);
|
|
304
|
+
attempts.push({
|
|
305
|
+
logName,
|
|
306
|
+
note: err.message,
|
|
307
|
+
log: await tailQuiet(host, logName, signal, useLocal),
|
|
308
|
+
});
|
|
309
|
+
throw failure(host, attempts, err);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// S3
|
|
313
|
+
if (poll.outcome === 'url') {
|
|
314
|
+
const { fingerprint, cwd } = await captureFingerprint(
|
|
315
|
+
host,
|
|
316
|
+
{ pid, port: poll.actualPort },
|
|
317
|
+
{ local: useLocal, signal },
|
|
318
|
+
);
|
|
319
|
+
return {
|
|
320
|
+
pid, actualPort: poll.actualPort, logName, fingerprint, cwd,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// S4:降级重拉(仅允许一次)
|
|
325
|
+
if (poll.outcome === 'bind-err' && !fallbackUsed) {
|
|
326
|
+
// 旧 pid 若仍活着:指纹尚未捕获,以「本次 LAUNCH 拼装的命令行」作临时指纹做全等;
|
|
327
|
+
// 不匹配就放着不杀(12 §5.1 R6:宁可留一个可见的孤儿)
|
|
328
|
+
await orphanCleanup(host, pid, signal, useLocal);
|
|
329
|
+
attempts.push({
|
|
330
|
+
logName,
|
|
331
|
+
note: `端口 ${usePort} 被占,降级 --port 0 重拉`,
|
|
332
|
+
log: await tailQuiet(host, logName, signal, useLocal),
|
|
333
|
+
});
|
|
334
|
+
fallbackUsed = true;
|
|
335
|
+
usePort = '0';
|
|
336
|
+
logName = autoLogName();
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// S5 FAIL 收尾
|
|
341
|
+
await orphanCleanup(host, pid, signal, useLocal);
|
|
342
|
+
const note = {
|
|
343
|
+
'bind-err': '端口被占且降级重拉后仍被占',
|
|
344
|
+
dead: '远端进程启动后立即退出',
|
|
345
|
+
timeout: `启动超时(${PROTO_TIMING.pollMaxAttempts} 拍内未见监听 URL)`,
|
|
346
|
+
}[poll.outcome];
|
|
347
|
+
attempts.push({
|
|
348
|
+
logName,
|
|
349
|
+
note,
|
|
350
|
+
log: await tailQuiet(host, logName, signal, useLocal),
|
|
351
|
+
});
|
|
352
|
+
throw failure(host, attempts, null);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** S5b:失败但进程残留时尽力清理(指纹未知,只能按 pid 存活与否决定要不要出手)。 */
|
|
357
|
+
async function orphanCleanup(host, pid, signal, local) {
|
|
358
|
+
try {
|
|
359
|
+
const v = await verifyRemote(host, { pid, port: 1 }, { local, signal });
|
|
360
|
+
if (!v.alive || !v.argsRaw) return;
|
|
361
|
+
await stopRemote(host, { pid, fingerprint: v.argsRaw }, { local, signal });
|
|
362
|
+
} catch {
|
|
363
|
+
// 清理是 best-effort:失败只会留下一个下轮探测可见的孤儿(12 §5.1 R6/R7)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function failure(host, attempts, cause) {
|
|
368
|
+
const detail = attempts
|
|
369
|
+
.map((a, i) => `── 第 ${i + 1} 次拉起(${a.logName}):${a.note}\n${a.log}`)
|
|
370
|
+
.join('\n\n');
|
|
371
|
+
// 病因的 detail(如 workdir 的 WD= 回显、协议解析的原始 stdout)比日志尾更直指问题
|
|
372
|
+
const cited = cause?.detail ? `${cause.detail}\n\n` : '';
|
|
373
|
+
return new DshError('LAUNCH_FAILED', `拉起失败(${host}):${attempts.at(-1)?.note ?? '未知原因'}`, {
|
|
374
|
+
host,
|
|
375
|
+
detail: cited + detail,
|
|
376
|
+
cause: cause ?? undefined,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── ENG-11 编排(02 §3.2 / §3.5 / §3.1 第 2 步) ─────────────────────────
|
|
381
|
+
|
|
382
|
+
export const START_PHASES = Object.freeze(['ready', 'crashed']);
|
|
383
|
+
export const STOP_PHASES = Object.freeze(['running', 'degraded']);
|
|
384
|
+
|
|
385
|
+
/** 队列内复检(11 §2.3 第 2 层,TOCTOU 防护):排队期间 phase 可能已被隧道/巡检改掉。 */
|
|
386
|
+
function recheckPhase(name, allowed, action) {
|
|
387
|
+
const phase = store.getPhase(name);
|
|
388
|
+
if (!allowed.includes(phase)) {
|
|
389
|
+
const err = new DshError('PHASE_CONFLICT', `${action} 被取消:主机当前状态为 ${phase}`, { host: name });
|
|
390
|
+
logEvent(name, 'warn', err.message);
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
return phase;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 全流程 start(02 §3.2 × 12 §3)。队列内执行;任一步失败回滚 ready 并清远端孤儿。
|
|
398
|
+
* @returns {Promise<{pid:number, actualPort:number, localPort:number}>}
|
|
399
|
+
*/
|
|
400
|
+
export function start(name) {
|
|
401
|
+
return hostQueue(name).run('start', async (signal) => {
|
|
402
|
+
const view = store.getHostView(name);
|
|
403
|
+
if (!view) throw new DshError('NOT_FOUND', `未知主机 ${name}`, { host: name });
|
|
404
|
+
if (!view.config.enabled) {
|
|
405
|
+
throw new DshError('NOT_ALLOWED', `主机 ${name} 已在配置中停用`, { host: name });
|
|
406
|
+
}
|
|
407
|
+
recheckPhase(name, START_PHASES, '启动');
|
|
408
|
+
const local = view.local === true;
|
|
409
|
+
|
|
410
|
+
store.setPhase(name, 'starting', 'launcher.start');
|
|
411
|
+
|
|
412
|
+
/** @type {{pid:number, actualPort:number, logName:string, fingerprint:string, cwd:string|null}|null} */
|
|
413
|
+
let launched = null;
|
|
414
|
+
try {
|
|
415
|
+
const allocatedPort = local ? null : await ensureLocalPort(name);
|
|
416
|
+
const remotePort = store.effectiveRemotePort(name);
|
|
417
|
+
|
|
418
|
+
const sync = await syncPatches(
|
|
419
|
+
name,
|
|
420
|
+
view.config.inject.patches,
|
|
421
|
+
view.patchSync,
|
|
422
|
+
{ local, signal },
|
|
423
|
+
);
|
|
424
|
+
if (sync.uploaded > 0 || sync.skipped > 0) {
|
|
425
|
+
store.mutateHostState(name, (st) => { st.patchSync = sync.patchSync; });
|
|
426
|
+
logEvent(name, 'info', `patch 同步完成:上载 ${sync.uploaded}、跳过 ${sync.skipped}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 下次拉起才生效的语义与 inject 完全一致:这里读的是 config 现值
|
|
430
|
+
const workdir = view.config.workdir ?? null;
|
|
431
|
+
|
|
432
|
+
launched = await runLaunchSequence(name, {
|
|
433
|
+
port: remotePort,
|
|
434
|
+
env: view.config.inject.env,
|
|
435
|
+
extraArgs: view.config.inject.extraArgs,
|
|
436
|
+
patchRemoteNames: sync.remoteNames,
|
|
437
|
+
workdir,
|
|
438
|
+
}, { local, signal });
|
|
439
|
+
|
|
440
|
+
store.mutateHostState(name, (st) => {
|
|
441
|
+
st.web = {
|
|
442
|
+
pid: launched.pid,
|
|
443
|
+
port: launched.actualPort,
|
|
444
|
+
startedByUs: true,
|
|
445
|
+
cmdFingerprint: launched.fingerprint,
|
|
446
|
+
log: launched.logName,
|
|
447
|
+
startedAt: new Date().toISOString(),
|
|
448
|
+
// 本次实例实际生效值;与 config.workdir 不等 = 前端的「重启后生效」态
|
|
449
|
+
workdir,
|
|
450
|
+
// 远端实测 cwd(best-effort,仅展示与诊断)
|
|
451
|
+
cwd: launched.cwd,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const localPort = local ? launched.actualPort : allocatedPort;
|
|
456
|
+
await tunnel.open(name, {
|
|
457
|
+
localPort,
|
|
458
|
+
remotePort: launched.actualPort,
|
|
459
|
+
direct: local,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
store.setPhase(name, 'running', 'launcher.start');
|
|
463
|
+
logEvent(
|
|
464
|
+
name,
|
|
465
|
+
'info',
|
|
466
|
+
local
|
|
467
|
+
? `已启动 pid=${launched.pid} web 端口=${launched.actualPort}(本机直连)`
|
|
468
|
+
: `已启动 pid=${launched.pid} 远端端口=${launched.actualPort} 本机端口=${localPort}`,
|
|
469
|
+
);
|
|
470
|
+
return { pid: launched.pid, actualPort: launched.actualPort, localPort };
|
|
471
|
+
} catch (err) {
|
|
472
|
+
await rollbackStart(name, launched, signal, local);
|
|
473
|
+
const e = err instanceof DshError ? err : new DshError('INTERNAL', err.message, { host: name, cause: err });
|
|
474
|
+
logEvent(name, 'error', `启动失败:${e.message}`, e.detail ?? null);
|
|
475
|
+
throw e;
|
|
476
|
+
}
|
|
477
|
+
}, { timeoutMs: PROTO_TIMING.startBudgetMs });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** 回滚(12 §3.6):远端已拉起就按指纹停掉,state 清空,phase 回 ready。 */
|
|
481
|
+
async function rollbackStart(name, launched, signal, local) {
|
|
482
|
+
try {
|
|
483
|
+
await tunnel.close(name);
|
|
484
|
+
} catch {
|
|
485
|
+
// 隧道尚未建立或已死,无需处理
|
|
486
|
+
}
|
|
487
|
+
if (launched) {
|
|
488
|
+
try {
|
|
489
|
+
const res = await stopRemote(
|
|
490
|
+
name,
|
|
491
|
+
{ pid: launched.pid, fingerprint: launched.fingerprint },
|
|
492
|
+
{ local, signal },
|
|
493
|
+
);
|
|
494
|
+
if (res.killed === 'no') {
|
|
495
|
+
logEvent(
|
|
496
|
+
name,
|
|
497
|
+
'warn',
|
|
498
|
+
`回滚时拒杀 pid=${launched.pid}(指纹不符),${local ? '本机' : '远端'}可能留下孤儿实例`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
} catch (err) {
|
|
502
|
+
logEvent(name, 'warn', `回滚清理${local ? '本机' : '远端'}失败:${err.message}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
store.mutateHostState(name, (st) => {
|
|
506
|
+
st.web = null;
|
|
507
|
+
st.tunnel = null;
|
|
508
|
+
});
|
|
509
|
+
if (store.getPhase(name) === 'starting') store.setPhase(name, 'ready', 'launcher.start.rollback');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* stop(02 §3.5):远端隧道仍先关;本机 direct 只在 STOP 明确成功后清。
|
|
514
|
+
* 随后清 state.web → ready。
|
|
515
|
+
* KILLED=no → 抛 KILL_REFUSED 且 state 不动(人工裁决,README 不误杀契约)。
|
|
516
|
+
*/
|
|
517
|
+
export function stop(name) {
|
|
518
|
+
return hostQueue(name).run('stop', async (signal) => {
|
|
519
|
+
const st = store.getHostState(name);
|
|
520
|
+
const view = store.getHostView(name);
|
|
521
|
+
if (!view) throw new DshError('NOT_FOUND', `未知主机 ${name}`, { host: name });
|
|
522
|
+
if (!st?.web?.startedByUs) {
|
|
523
|
+
throw new DshError('NOT_ALLOWED', `主机 ${name} 上没有由本 manager 拉起的实例,拒绝关停`, { host: name });
|
|
524
|
+
}
|
|
525
|
+
recheckPhase(name, STOP_PHASES, '关停');
|
|
526
|
+
|
|
527
|
+
const { pid, cmdFingerprint } = st.web;
|
|
528
|
+
const local = view.local === true;
|
|
529
|
+
// 远端须先断开转发,维持既有关闭时序;本机 direct 是 mappedUrl/巡检的事实记录,
|
|
530
|
+
// STOP 拒杀、超时或失败时仍应保留,不能先删成 running + no-tunnel。
|
|
531
|
+
if (!local) await tunnel.close(name);
|
|
532
|
+
|
|
533
|
+
const res = await stopRemote(
|
|
534
|
+
name,
|
|
535
|
+
{ pid, fingerprint: cmdFingerprint },
|
|
536
|
+
{ local, signal, timeoutMs: stopTimeoutMs },
|
|
537
|
+
);
|
|
538
|
+
if (res.killed === 'no') {
|
|
539
|
+
const err = killRefused(
|
|
540
|
+
name,
|
|
541
|
+
{ pid, expected: cmdFingerprint, actual: res.actualArgs },
|
|
542
|
+
{ local },
|
|
543
|
+
);
|
|
544
|
+
logEvent(name, 'error', err.message, err.detail);
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (local) await tunnel.close(name);
|
|
549
|
+
store.mutateHostState(name, (entry) => {
|
|
550
|
+
entry.web = null;
|
|
551
|
+
entry.tunnel = null;
|
|
552
|
+
});
|
|
553
|
+
store.setPhase(name, 'ready', 'launcher.stop');
|
|
554
|
+
logEvent(name, 'info', `已关停 pid=${pid}(${res.killed})`);
|
|
555
|
+
return { killed: res.killed };
|
|
556
|
+
}, { timeoutMs: PROTO_TIMING.onceTimeoutMs + 10_000 });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** stop → start,复用同一 localPort(config 中已固定)。 */
|
|
560
|
+
export async function restart(name) {
|
|
561
|
+
const phase = store.getPhase(name);
|
|
562
|
+
if (STOP_PHASES.includes(phase)) await stop(name);
|
|
563
|
+
return start(name);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 启动恢复复核(02 §3.1 第 2 步):state 里 running/degraded 的主机是否还真活着。
|
|
568
|
+
* @returns {Promise<'running'|'crashed'>}
|
|
569
|
+
*/
|
|
570
|
+
export async function recoverOne(name) {
|
|
571
|
+
return hostQueue(name).run('recover', async (signal) => {
|
|
572
|
+
const view = store.getHostView(name);
|
|
573
|
+
const local = view?.local === true;
|
|
574
|
+
const st = store.getHostState(name);
|
|
575
|
+
// 上一代 manager 的隧道子进程随其退出而死:state 里的隧道记录一律作废(契约疑议 3 口径)
|
|
576
|
+
if (st?.tunnel) store.mutateHostState(name, (e) => { e.tunnel = null; });
|
|
577
|
+
|
|
578
|
+
const web = st?.web;
|
|
579
|
+
if (!web?.pid || !web?.cmdFingerprint) {
|
|
580
|
+
logEvent(name, 'warn', '恢复复核:state 无受管实例记录,标记 crashed');
|
|
581
|
+
return toCrashed(name);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let v;
|
|
585
|
+
try {
|
|
586
|
+
v = await verifyRemote(
|
|
587
|
+
name,
|
|
588
|
+
{ pid: web.pid, port: web.port ?? 1, fingerprint: web.cmdFingerprint },
|
|
589
|
+
{ local, signal },
|
|
590
|
+
);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
logEvent(name, 'warn', `恢复复核失败(${err.message}),标记 crashed`, err.detail ?? null);
|
|
593
|
+
return toCrashed(name);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!v.alive || v.fingerprintMatch === false) {
|
|
597
|
+
logEvent(name, 'warn', v.alive ? '恢复复核:指纹不符,标记 crashed' : '恢复复核:远端进程已消失,标记 crashed');
|
|
598
|
+
return toCrashed(name);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 接管的是上一代 manager 拉起的进程:config.workdir 未必是它的实际 cwd,
|
|
602
|
+
// 把实测值记下来给抽屉显示(不校验、不据此拒绝接管,§4.3)
|
|
603
|
+
store.mutateHostState(name, (e) => { e.web = { ...e.web, cwd: v.cwd }; });
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
const localPort = local ? web.port : await ensureLocalPort(name);
|
|
607
|
+
await tunnel.open(name, {
|
|
608
|
+
localPort,
|
|
609
|
+
remotePort: web.port,
|
|
610
|
+
direct: local,
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
logEvent(name, 'error', `恢复隧道失败:${err.message}`, err.detail ?? null);
|
|
614
|
+
return toCrashed(name);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
store.setPhase(name, 'running', 'launcher.recoverOne');
|
|
618
|
+
logEvent(name, 'info', local ? `恢复接管 pid=${web.pid},本机直连已登记` : `恢复接管 pid=${web.pid},隧道已重建`);
|
|
619
|
+
return 'running';
|
|
620
|
+
}, { timeoutMs: PROTO_TIMING.startBudgetMs });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function toCrashed(name) {
|
|
624
|
+
const phase = store.getPhase(name);
|
|
625
|
+
if (phase !== 'crashed') store.setPhase(name, 'crashed', 'launcher.recoverOne');
|
|
626
|
+
return 'crashed';
|
|
627
|
+
}
|