@capyclaw/service-broker 0.0.0-stage → 0.2.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/README.md +100 -2
- package/cordis.patch.yml +6 -0
- package/dsh-suite.json +21 -0
- package/lib/control.mjs +117 -0
- package/lib/discovery.mjs +47 -0
- package/lib/engine.mjs +676 -0
- package/lib/host.mjs +93 -0
- package/lib/manifest.mjs +40 -0
- package/lib/recovery.mjs +206 -0
- package/lib/skills-client.mjs +28 -0
- package/lib/spec.mjs +78 -0
- package/package.json +49 -4
- package/server.mjs +73 -0
- package/service.schema.json +42 -0
- package/services.json +3 -0
package/lib/engine.mjs
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-service-broker 引擎:服务编排核心。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(对应服务解耦标准):
|
|
5
|
+
* - 接收「结构化命令」(清单或外部投递),把每个服务作为**独立进程**拉起;
|
|
6
|
+
* - 服务之间不互相感知、不互相带动;跨端口组织只发生在这里;
|
|
7
|
+
* - 复用优先:目标端口已有健康实例(/healthz 且带 pid)则收编不重启;
|
|
8
|
+
* - keepAlive 守护:进程死亡自动按原端口重启(限次);
|
|
9
|
+
* - 运行事实写入 discovery 注册表(discovery.mjs),供 UI/侧栏/外部查询。
|
|
10
|
+
*
|
|
11
|
+
* 引擎不 import 任何 dsh 代码:embedded(cordis 插件壳)与 standalone
|
|
12
|
+
* (server.mjs)两种形态共用这一份逻辑。
|
|
13
|
+
*/
|
|
14
|
+
import net from 'node:net'
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { loadDiscovery, saveDiscovery } from './discovery.mjs'
|
|
20
|
+
import { marker, markerAsync, absent, receiptPath, writeReceipt, removeReceipt, receiptResolvable, waitReceiptResolvable } from './recovery.mjs'
|
|
21
|
+
|
|
22
|
+
const VAR_RE = /\$\{([^{}]+)\}/g
|
|
23
|
+
const IS_WIN = process.platform === 'win32'
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ *
|
|
26
|
+
* 基础探测原语(与 dsh-suite-kit/src/runtime.mjs 同口径)
|
|
27
|
+
* ------------------------------------------------------------------ */
|
|
28
|
+
|
|
29
|
+
export function pidAlive(pid) {
|
|
30
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
31
|
+
try { process.kill(pid, 0); return true } catch (err) { return err.code === 'EPERM' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// OS creation identity, not a health endpoint's self-reported PID. Unsupported or
|
|
35
|
+
// inaccessible platforms deliberately return null and cannot authorize a kill.
|
|
36
|
+
export function processIdentity(pid) {
|
|
37
|
+
return marker(pid)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function canConnect(port, timeoutMs = 800) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const socket = net.connect({ port, host: '127.0.0.1' })
|
|
43
|
+
const done = (ok) => { try { socket.destroy() } catch { /* 已销毁 */ } resolve(ok) }
|
|
44
|
+
socket.setTimeout(timeoutMs)
|
|
45
|
+
socket.once('connect', () => done(true))
|
|
46
|
+
socket.once('timeout', () => done(false))
|
|
47
|
+
socket.once('error', () => done(false))
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** bind :0 取一个当前空闲端口(分配后立刻释放,存在理论竞争,和 works_runtime 同口径)。 */
|
|
52
|
+
export function allocatePort() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = net.createServer()
|
|
55
|
+
server.once('error', reject)
|
|
56
|
+
server.listen(0, '127.0.0.1', () => {
|
|
57
|
+
const { port } = server.address()
|
|
58
|
+
server.close(() => resolve(port))
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function probeHttp(url, timeoutMs = 1500) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
66
|
+
return { ok: res.ok, status: res.status, body: await res.text() }
|
|
67
|
+
} catch {
|
|
68
|
+
return { ok: false, status: 0, body: '' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 健康探测并尽力取回对端自报的 pid(服务标准:/healthz 带 pid 字段)。
|
|
73
|
+
* 返回:null=不健康;-1=健康但无 pid;>0=健康且自报 pid。 */
|
|
74
|
+
export async function healthzPid(url, timeoutMs = 1500) {
|
|
75
|
+
const hit = await probeHttp(url, timeoutMs)
|
|
76
|
+
if (!hit.ok) return null
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(hit.body)
|
|
79
|
+
return Number.isInteger(parsed?.pid) && parsed.pid > 0 ? parsed.pid : -1
|
|
80
|
+
} catch {
|
|
81
|
+
return -1
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ------------------------------------------------------------------ *
|
|
86
|
+
* 规格规范化与 ${VAR} 展开(与桌面后端 works_services 契约对齐)
|
|
87
|
+
* ------------------------------------------------------------------ */
|
|
88
|
+
|
|
89
|
+
export function normalizeSpec(raw) {
|
|
90
|
+
if (!raw || typeof raw !== 'object') throw new Error('service spec 必须是对象')
|
|
91
|
+
const spec = { ...raw }
|
|
92
|
+
if (typeof spec.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/i.test(spec.id)) {
|
|
93
|
+
throw new Error(`service spec.id 非法: ${JSON.stringify(spec?.id)}`)
|
|
94
|
+
}
|
|
95
|
+
if (spec.cmd !== undefined) {
|
|
96
|
+
if (!Array.isArray(spec.cmd) || !spec.cmd.length || !spec.cmd.every((a) => typeof a === 'string')) {
|
|
97
|
+
throw new Error(`service ${spec.id}: cmd 必须是非空字符串数组`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (spec.requires !== undefined && !Array.isArray(spec.requires)) {
|
|
101
|
+
throw new Error(`service ${spec.id}: requires 必须是数组`)
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
cmd: undefined,
|
|
105
|
+
env: {},
|
|
106
|
+
cwd: undefined,
|
|
107
|
+
ports: {},
|
|
108
|
+
url: undefined,
|
|
109
|
+
healthPath: '/healthz',
|
|
110
|
+
keepAlive: true,
|
|
111
|
+
requires: [],
|
|
112
|
+
logFile: undefined,
|
|
113
|
+
...spec,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function specHash(spec) {
|
|
118
|
+
const pick = (({ cmd, env, cwd, ports, url, healthPath }) =>
|
|
119
|
+
JSON.stringify({ cmd, env, cwd, ports, url, healthPath }))(spec)
|
|
120
|
+
return Buffer.from(pick).toString('base64url')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** ${VAR} 展开:ctx 缺失的变量 → 空串(诚实降级,与桌面后端同口径)。 */
|
|
124
|
+
export function expandVars(value, ctx) {
|
|
125
|
+
return String(value).replace(VAR_RE, (_, name) => (name in ctx ? String(ctx[name]) : ''))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function expandSpec(spec, ctx) {
|
|
129
|
+
const out = { ...spec, env: { ...spec.env } }
|
|
130
|
+
if (Array.isArray(out.cmd)) out.cmd = out.cmd.map((a) => expandVars(a, ctx))
|
|
131
|
+
for (const key of ['url', 'cwd', 'logFile']) {
|
|
132
|
+
if (typeof out[key] === 'string') out[key] = expandVars(out[key], ctx)
|
|
133
|
+
}
|
|
134
|
+
for (const [k, v] of Object.entries(out.env)) out.env[k] = expandVars(v, ctx)
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ *
|
|
139
|
+
* 引擎
|
|
140
|
+
* ------------------------------------------------------------------ */
|
|
141
|
+
|
|
142
|
+
export class ServiceEngine {
|
|
143
|
+
/**
|
|
144
|
+
* @param {{
|
|
145
|
+
* discoveryPath?: string, // discovery 注册表落点,缺省 ~/.dsh/services.json
|
|
146
|
+
* logDir?: string, // 预留:服务日志落点,缺省 <discovery 目录>/logs
|
|
147
|
+
* log?: (msg: string) => void,
|
|
148
|
+
* defaultWaitMs?: number,
|
|
149
|
+
* }} options
|
|
150
|
+
*/
|
|
151
|
+
constructor(options = {}) {
|
|
152
|
+
// discovery 是用户级事实表:固定 ~/.dsh/services.json(DSH_DISCOVERY_FILE
|
|
153
|
+
// 可覆盖)。不随 DSH_HOME 走——broker 可能由网关拉起(无 DSH_HOME),
|
|
154
|
+
// 而 dsh 内的消费者(侧栏等)带着自己的 DSH_HOME,两边必须指向同一张表。
|
|
155
|
+
const discoveryPath = options.discoveryPath
|
|
156
|
+
|| process.env.DSH_DISCOVERY_FILE
|
|
157
|
+
|| path.join(os.homedir(), '.dsh', 'services.json')
|
|
158
|
+
this.discoveryPath = discoveryPath
|
|
159
|
+
this.logDir = options.logDir || path.join(path.dirname(discoveryPath), 'logs')
|
|
160
|
+
this.log = options.log || ((msg) => console.log(`[service-broker] ${msg}`))
|
|
161
|
+
this.defaultWaitMs = options.defaultWaitMs ?? 20000
|
|
162
|
+
this._guardTimer = null
|
|
163
|
+
this._guardFailures = new Map() // id → 连续重启次数
|
|
164
|
+
this._lastSpecs = new Map() // id → 最近一次 up 的原始 spec(守护重启用)
|
|
165
|
+
this._upLocks = new Map() // id → 启动互斥尾(同 id 并发 up 排队)
|
|
166
|
+
this._regLock = null // 注册表读-改-写互斥尾
|
|
167
|
+
this._stopping = false
|
|
168
|
+
this._owned = new Map() // per-engine authority, never restored from discovery
|
|
169
|
+
// broker 自身的创建身份在本进程生命周期内不变;Windows 上 marker() 约 900ms,
|
|
170
|
+
// 每次 up 重算会白烧近 1 秒。null 时不缓存(瞬时查询失败可重试)。
|
|
171
|
+
this._brokerMarker = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ---------- discovery / 状态 ---------- */
|
|
175
|
+
|
|
176
|
+
/** 注册表读-改-写互斥:并发的 adopt/stop/守护标记在同一把进程内锁里排队,
|
|
177
|
+
* 保证后写者基于前写者的结果(否则并发拉起会互相覆盖丢行)。 */
|
|
178
|
+
_locked(fn) {
|
|
179
|
+
const run = (this._regLock = this._regLock || Promise.resolve()).then(fn, fn)
|
|
180
|
+
this._regLock = run.then(() => {}, () => {})
|
|
181
|
+
return run
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async _loadRegistry() {
|
|
185
|
+
const reg = await loadDiscovery(this.discoveryPath)
|
|
186
|
+
return reg?.services && typeof reg.services === 'object' ? reg.services : {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async _saveRegistry(services) {
|
|
190
|
+
await saveDiscovery(this.discoveryPath, {
|
|
191
|
+
version: 1,
|
|
192
|
+
updatedAt: new Date().toISOString(),
|
|
193
|
+
services,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async list() {
|
|
198
|
+
const services = await this._loadRegistry()
|
|
199
|
+
return Object.values(services).map((row) => ({
|
|
200
|
+
...row,
|
|
201
|
+
running: row.state === 'running' && pidAlive(row.pid),
|
|
202
|
+
}))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async statusOf(id) {
|
|
206
|
+
const row = (await this._loadRegistry())[id]
|
|
207
|
+
return row ? { ...row, running: row.state === 'running' && pidAlive(row.pid) } : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async doctor(id) {
|
|
211
|
+
const row = await this.statusOf(id)
|
|
212
|
+
if (!row) return { id, state: 'unknown', issues: ['discovery 无此服务记录'] }
|
|
213
|
+
const issues = []
|
|
214
|
+
if (!pidAlive(row.pid)) issues.push(`pid ${row.pid ?? '-'} 不存活`)
|
|
215
|
+
const health = row.url ? await healthzPid(this._healthUrl(row)) : null
|
|
216
|
+
if (health === null) issues.push('健康端点无响应')
|
|
217
|
+
return { ...row, issues, healthy: issues.length === 0 }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 健康端点完整地址(discovery 行带自己当初的 healthPath)。 */
|
|
221
|
+
_healthUrl(row) {
|
|
222
|
+
return row.url + (row.healthPath || '/healthz')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ---------- 端口与占位符 ---------- */
|
|
226
|
+
|
|
227
|
+
async _resolvePorts(spec) {
|
|
228
|
+
const ports = {}
|
|
229
|
+
const declared = spec.ports && typeof spec.ports === 'object' ? spec.ports : {}
|
|
230
|
+
const names = Object.keys(declared)
|
|
231
|
+
if (!names.includes('main')) names.push('main')
|
|
232
|
+
for (const name of names) {
|
|
233
|
+
const want = declared[name]
|
|
234
|
+
if (want === 'auto' || want === undefined || want === null || want === '') {
|
|
235
|
+
ports[name] = await allocatePort()
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
const num = Number(want)
|
|
239
|
+
if (!Number.isInteger(num) || num <= 0) throw new Error(`service ${spec.id}: 端口 ${name}=${want} 非法`)
|
|
240
|
+
ports[name] = num
|
|
241
|
+
}
|
|
242
|
+
return ports
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_placeholderCtx(ports) {
|
|
246
|
+
const ctx = {}
|
|
247
|
+
for (const [name, port] of Object.entries(ports || {})) {
|
|
248
|
+
ctx[name === 'main' ? 'PORT' : `PORT_${name.toUpperCase()}`] = String(port)
|
|
249
|
+
}
|
|
250
|
+
return ctx
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_urlFor(spec, ports) {
|
|
254
|
+
const main = ports.main
|
|
255
|
+
if (!main) throw new Error(`service ${spec.id}: 缺少主端口`)
|
|
256
|
+
return spec.url
|
|
257
|
+
? expandVars(spec.url, this._placeholderCtx(ports)).replace(/\/$/, '')
|
|
258
|
+
: `http://127.0.0.1:${main}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ---------- 拉起 / 复用 ---------- */
|
|
262
|
+
|
|
263
|
+
/** 同一服务的并发 up 必须排队:首个执行拉起,其余落到复用路径。
|
|
264
|
+
* 否则竞态下会各自分配端口各自 spawn,制造出 N 个"同名服务"。
|
|
265
|
+
* 锁表按 id 保留(服务数量有限,不做清理)。 */
|
|
266
|
+
up(rawSpec, { waitMs } = {}) {
|
|
267
|
+
const id = rawSpec && rawSpec.id
|
|
268
|
+
const prev = this._upLocks.get(id) || Promise.resolve()
|
|
269
|
+
const run = prev.catch(() => {}).then(() => this._upOnce(rawSpec, { waitMs }))
|
|
270
|
+
this._upLocks.set(id, run.catch(() => {}))
|
|
271
|
+
return run
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _upOnce(rawSpec, { waitMs } = {}) {
|
|
275
|
+
const spec = normalizeSpec(rawSpec)
|
|
276
|
+
const id = spec.id
|
|
277
|
+
const wait = waitMs ?? this.defaultWaitMs
|
|
278
|
+
const deadline = Date.now() + wait
|
|
279
|
+
|
|
280
|
+
// A previous broker's unresolved spawn cannot be made safe by a healthy
|
|
281
|
+
// endpoint. Only this engine's own verified process may bypass the receipt.
|
|
282
|
+
const prev = (await this._loadRegistry())[id]
|
|
283
|
+
if (existsSync(receiptPath(this.discoveryPath, id))) {
|
|
284
|
+
let existing
|
|
285
|
+
try { existing = JSON.parse(readFileSync(receiptPath(this.discoveryPath, id), 'utf8')) } catch {}
|
|
286
|
+
const owned = this._owned.get(id)
|
|
287
|
+
const local = existing?.state === 'spawned' && existing.brokerPid === process.pid && existing.pid === owned?.pid && existing.identity === owned?.identity && owned?.identity && processIdentity(owned.pid) === owned.identity
|
|
288
|
+
const liveOwner = existing?.id === id && existing.state === 'spawned' && existing.brokerMarker && marker(existing.brokerPid) === existing.brokerMarker && prev?.state === 'running' && prev.ownership === 'owned' && prev.pid === existing.pid && existing.identity && processIdentity(existing.pid) === existing.identity
|
|
289
|
+
if (!local && !liveOwner) {
|
|
290
|
+
// A dead root whose declared ports are all free left nothing of this
|
|
291
|
+
// service behind; keeping the receipt would block the product forever.
|
|
292
|
+
if (await receiptResolvable(existing, prev?.ports)) removeReceipt(this.discoveryPath, id)
|
|
293
|
+
else throw new Error(`service ${id}: 存在未解决的归属记录,拒绝启动或收编`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 1) 复用优先:discovery 有存活记录且健康端点有响应 → 收编,不重启
|
|
298
|
+
if (prev?.url && prev.state === 'running') {
|
|
299
|
+
const pid = await healthzPid(this._healthUrl(prev))
|
|
300
|
+
if (pid !== null) {
|
|
301
|
+
this._guardFailures.delete(id)
|
|
302
|
+
this.log(`✓ ${id} 复用已运行实例 ${prev.url}(pid ${pid > 0 ? pid : '未知'})`)
|
|
303
|
+
this._lastSpecs.set(id, spec)
|
|
304
|
+
return this._adopt(id, spec, prev.url, pid, prev.ports, true)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2) 按声明解析端口 → 展开占位符 → 校验 → spawn
|
|
309
|
+
const ports = await this._resolvePorts(spec)
|
|
310
|
+
const ctx = { ...process.env, ...spec.env, ...this._placeholderCtx(ports) }
|
|
311
|
+
const expanded = expandSpec(spec, ctx)
|
|
312
|
+
if (!Array.isArray(expanded.cmd) || !expanded.cmd.length) {
|
|
313
|
+
throw new Error(`service ${id}: 缺少 cmd,无法拉起`)
|
|
314
|
+
}
|
|
315
|
+
const exe = expanded.cmd[0]
|
|
316
|
+
// 绝对路径不存在 → 诚实报错(${VAR} 展开失败最常见的样子);
|
|
317
|
+
// 裸命令名(node/npx…)交给 PATH 解析,spawn 失败会在下面捕获。
|
|
318
|
+
if (/^([a-z]:\\|\/)/i.test(exe) && !existsSync(exe)) {
|
|
319
|
+
throw new Error(`service ${id}: cmd 可执行文件不存在: ${exe}`)
|
|
320
|
+
}
|
|
321
|
+
const url = this._urlFor(expanded, ports)
|
|
322
|
+
const healthUrl = url + (expanded.healthPath || '/healthz')
|
|
323
|
+
|
|
324
|
+
// 目标端口已被占但非本服务(健康端点无响应)→ 不抢,诚实失败
|
|
325
|
+
if (await canConnect(ports.main)) {
|
|
326
|
+
const pid = await healthzPid(healthUrl)
|
|
327
|
+
if (pid === null) throw new Error(`service ${id}: 端口 ${ports.main} 已被其他进程占用`)
|
|
328
|
+
// 健康但 discovery 无记录(比如 broker 换了 discovery 文件)→ 收编
|
|
329
|
+
this.log(`✓ ${id} 端口上发现健康实例,收编 ${url}`)
|
|
330
|
+
this._lastSpecs.set(id, spec)
|
|
331
|
+
return this._adopt(id, spec, url, pid, ports, true)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Never overwrite an unresolved receipt, including a crash in the spawn window.
|
|
335
|
+
if (existsSync(receiptPath(this.discoveryPath, id))) throw new Error(`service ${id}: 存在未解决的归属记录,拒绝启动`)
|
|
336
|
+
const brokerMarker = this._brokerMarker ?? (this._brokerMarker = marker(process.pid))
|
|
337
|
+
if (!brokerMarker) throw new Error(`service ${id}: 无法验证 broker 身份,拒绝启动`)
|
|
338
|
+
const receipt = { id, brokerPid: process.pid, brokerMarker, ports, state: 'pending' }
|
|
339
|
+
writeReceipt(this.discoveryPath, id, receipt)
|
|
340
|
+
const logFile = expanded.logFile || path.join(this.logDir, `service-${id}.log`)
|
|
341
|
+
this.log(`⏏ 拉起 ${id}: ${expanded.cmd.join(' ')}(日志 ${logFile})`)
|
|
342
|
+
let child
|
|
343
|
+
try {
|
|
344
|
+
child = spawn(expanded.cmd[0], expanded.cmd.slice(1), {
|
|
345
|
+
cwd: expanded.cwd || undefined,
|
|
346
|
+
env: ctx,
|
|
347
|
+
detached: true,
|
|
348
|
+
stdio: 'ignore',
|
|
349
|
+
windowsHide: true,
|
|
350
|
+
})
|
|
351
|
+
} catch (err) {
|
|
352
|
+
removeReceipt(this.discoveryPath, id)
|
|
353
|
+
throw new Error(`service ${id}: spawn 失败: ${err.message}`)
|
|
354
|
+
}
|
|
355
|
+
// Wait for the definitive spawn outcome before interpreting a missing PID.
|
|
356
|
+
// An asynchronous ENOENT has created no process and cannot leave descendants.
|
|
357
|
+
const spawnError = await new Promise((resolve) => {
|
|
358
|
+
child.once('spawn', () => resolve(null))
|
|
359
|
+
child.once('error', resolve)
|
|
360
|
+
})
|
|
361
|
+
// Retain an error listener after startup as ChildProcess may emit later errors.
|
|
362
|
+
child.on('error', () => {})
|
|
363
|
+
if (spawnError) {
|
|
364
|
+
if (!child.pid) removeReceipt(this.discoveryPath, id)
|
|
365
|
+
throw new Error(`service ${id}: spawn 失败: ${spawnError.message}`)
|
|
366
|
+
}
|
|
367
|
+
child.unref()
|
|
368
|
+
const childIdentity = await markerAsync(child.pid)
|
|
369
|
+
if (child.pid && childIdentity) writeReceipt(this.discoveryPath, id, { ...receipt, state: 'spawned', pid: child.pid, identity: childIdentity })
|
|
370
|
+
else if (!child.pid) removeReceipt(this.discoveryPath, id)
|
|
371
|
+
|
|
372
|
+
// 超时/早退时回收刚拉起的进程树——执行器不生产孤儿
|
|
373
|
+
const reapChild = () => {
|
|
374
|
+
if (!childIdentity || processIdentity(child.pid) !== childIdentity) return false
|
|
375
|
+
if (IS_WIN) {
|
|
376
|
+
const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
377
|
+
return result.status === 0
|
|
378
|
+
}
|
|
379
|
+
try { process.kill(child.pid, 'SIGTERM'); return true } catch { return false }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 3) 等健康
|
|
383
|
+
while (Date.now() < deadline) {
|
|
384
|
+
if (!pidAlive(child.pid)) {
|
|
385
|
+
/* 子进程退出 ≠ 服务失败:两种真实形态都要收编——
|
|
386
|
+
* a) allowReuse 服务发现端口上已有健康实例 → 直接返回并退出(relay reused 路径);
|
|
387
|
+
* b) 子进程派生/守护了真正的服务进程后自己退出(detached 孙进程还在启动)。
|
|
388
|
+
* 给一个短宽限窗持续探测健康端点,健康即收编,而不是把日志留成 error。
|
|
389
|
+
* (2026-11-02 稳定性测试实测;宽限窗上限 3s,避免病态服务拖长失败时间) */
|
|
390
|
+
const graceDeadline = Date.now() + Math.min(3000, Math.max(0, deadline - Date.now()))
|
|
391
|
+
for (;;) {
|
|
392
|
+
const adopted = await healthzPid(healthUrl)
|
|
393
|
+
if (adopted !== null) {
|
|
394
|
+
this._guardFailures.delete(id)
|
|
395
|
+
this.log(`✓ ${id} 子进程退出但端口上已有健康实例,收编 ${url}`)
|
|
396
|
+
this._lastSpecs.set(id, spec)
|
|
397
|
+
// Adopted as borrowed: no receipt, because this engine never gains
|
|
398
|
+
// authority to stop the healthy instance it did not spawn.
|
|
399
|
+
removeReceipt(this.discoveryPath, id)
|
|
400
|
+
return this._adopt(id, spec, url, adopted, ports, true)
|
|
401
|
+
}
|
|
402
|
+
if (Date.now() >= graceDeadline) break
|
|
403
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
404
|
+
}
|
|
405
|
+
// An exited wrapper may have left a daemon behind. Keep the receipt
|
|
406
|
+
// only while one of its declared ports is still occupied.
|
|
407
|
+
if (!(await receiptResolvable({ ...receipt, pid: child.pid, identity: childIdentity }, ports))) removeReceipt(this.discoveryPath, id)
|
|
408
|
+
else writeReceipt(this.discoveryPath, id, { ...receipt, state: 'unverified' })
|
|
409
|
+
throw new Error(`service ${id}: 进程在健康检查通过前退出(pid ${child.pid},日志 ${logFile})`)
|
|
410
|
+
}
|
|
411
|
+
const pid = await healthzPid(healthUrl)
|
|
412
|
+
if (pid !== null) {
|
|
413
|
+
this._guardFailures.delete(id)
|
|
414
|
+
this.log(`✓ ${id} 就绪 ${url}(pid ${pid > 0 ? pid : '未知'})`)
|
|
415
|
+
this._lastSpecs.set(id, spec)
|
|
416
|
+
const owned = pid === child.pid && childIdentity && processIdentity(child.pid) === childIdentity
|
|
417
|
+
return this._adopt(id, spec, url, pid, ports, !owned, child.pid, owned ? childIdentity : null)
|
|
418
|
+
}
|
|
419
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
420
|
+
}
|
|
421
|
+
/* 超时前最后一探:若健康端点已可用(服务慢启动/自治子进程),收编不杀。 */
|
|
422
|
+
const latePid = await healthzPid(healthUrl)
|
|
423
|
+
if (latePid !== null) {
|
|
424
|
+
this._guardFailures.delete(id)
|
|
425
|
+
this.log(`✓ ${id} 超时窗口后已就绪,收编 ${url}`)
|
|
426
|
+
this._lastSpecs.set(id, spec)
|
|
427
|
+
// Borrowed adoption: the healthy endpoint was not proven to be our child.
|
|
428
|
+
removeReceipt(this.discoveryPath, id)
|
|
429
|
+
return this._adopt(id, spec, url, latePid, ports, true)
|
|
430
|
+
}
|
|
431
|
+
const reaped = reapChild()
|
|
432
|
+
// Sending a signal, or even observing the root exit, does not verify its
|
|
433
|
+
// descendants. Retain the original receipt and identity across restarts.
|
|
434
|
+
throw new Error(`service ${id}: ${Math.round(wait / 1000)}s 内未通过健康检查(${healthUrl},日志 ${logFile});${reaped ? '已发送停止请求,但子进程清理未验证' : '未能安全回收启动进程'}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async _adopt(id, spec, url, healthPid, ports, reused, spawnPid, identity = null) {
|
|
438
|
+
const pid = healthPid && healthPid > 0 ? healthPid : (spawnPid ?? null)
|
|
439
|
+
const previous = this._owned.get(id)
|
|
440
|
+
// A wrapper/daemon or an unrelated endpoint cannot transfer authority.
|
|
441
|
+
// Borrowed adoptions must not leave a receipt behind: otherwise every
|
|
442
|
+
// later stop would report an ownership record it can never resolve.
|
|
443
|
+
if (reused && spawnPid && existsSync(receiptPath(this.discoveryPath, id))) {
|
|
444
|
+
removeReceipt(this.discoveryPath, id)
|
|
445
|
+
}
|
|
446
|
+
const ownedIdentity = identity || (previous?.pid === pid && processIdentity(pid) === previous.identity ? previous.identity : null)
|
|
447
|
+
if (ownedIdentity) this._owned.set(id, { pid, identity: ownedIdentity })
|
|
448
|
+
else this._owned.delete(id)
|
|
449
|
+
const row = {
|
|
450
|
+
id,
|
|
451
|
+
state: 'running',
|
|
452
|
+
url,
|
|
453
|
+
healthPath: spec.healthPath || '/healthz',
|
|
454
|
+
pid,
|
|
455
|
+
ports,
|
|
456
|
+
specHash: specHash(spec),
|
|
457
|
+
keepAlive: !!spec.keepAlive,
|
|
458
|
+
reused: ownedIdentity ? false : !!reused,
|
|
459
|
+
ownership: ownedIdentity ? 'owned' : 'borrowed',
|
|
460
|
+
startedAt: new Date().toISOString(),
|
|
461
|
+
}
|
|
462
|
+
return this._locked(async () => {
|
|
463
|
+
const services = await this._loadRegistry()
|
|
464
|
+
// A health response is not proof of ownership. In particular, another
|
|
465
|
+
// engine reusing this endpoint must not erase the owning engine's record.
|
|
466
|
+
if (!ownedIdentity && services[id]?.ownership === 'owned' && services[id]?.state === 'running') {
|
|
467
|
+
return { ...row, running: true }
|
|
468
|
+
}
|
|
469
|
+
services[id] = row
|
|
470
|
+
await this._saveRegistry(services)
|
|
471
|
+
return { ...row, reused: !!reused, running: true }
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* ---------- 停止 / 守护 ---------- */
|
|
476
|
+
|
|
477
|
+
stop(id, _options = {}) {
|
|
478
|
+
const prev = this._upLocks.get(id) || Promise.resolve()
|
|
479
|
+
const run = prev.catch(() => {}).then(() => this._stopOnce(id))
|
|
480
|
+
this._upLocks.set(id, run.catch(() => {}))
|
|
481
|
+
return run
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async _stopOnce(id) {
|
|
485
|
+
const row = (await this._loadRegistry())[id]
|
|
486
|
+
if (!row) return { id, state: 'unknown', stopped: false }
|
|
487
|
+
if (row.state === 'stopped') return { id, state: 'stopped', stopped: true }
|
|
488
|
+
const owned = this._owned.get(id)
|
|
489
|
+
if (!owned || owned.pid !== row.pid) {
|
|
490
|
+
return { id, state: row.state, stopped: false, reason: 'borrowed' }
|
|
491
|
+
}
|
|
492
|
+
const { pid, identity } = owned
|
|
493
|
+
if (!pidAlive(pid)) {
|
|
494
|
+
// Root already gone. Only clear the record when nothing listens on the
|
|
495
|
+
// declared ports; otherwise a detached descendant may still own them.
|
|
496
|
+
if (!(await waitReceiptResolvable({ pid, ports: row.ports }, row.ports))) {
|
|
497
|
+
throw new Error(`service ${id}: 根进程已退出且声明端口仍被占用,拒绝清除归属记录`)
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
if (!identity || processIdentity(pid) !== identity) {
|
|
501
|
+
throw new Error(`service ${id}: 进程身份无法验证或已变化,拒绝停止`)
|
|
502
|
+
}
|
|
503
|
+
if (IS_WIN) {
|
|
504
|
+
const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8', windowsHide: true, timeout: 10000 })
|
|
505
|
+
if (result.status !== 0 && pidAlive(pid)) throw new Error(`service ${id}: taskkill 失败: ${result.error?.message || result.stderr || result.status}`)
|
|
506
|
+
} else {
|
|
507
|
+
try { process.kill(pid, 'SIGTERM') } catch (err) {
|
|
508
|
+
if (err.code !== 'ESRCH') throw new Error(`service ${id}: 停止失败: ${err.message}`)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (let i = 0; i < 50 && pidAlive(pid) && processIdentity(pid) === identity; i++) {
|
|
512
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
513
|
+
}
|
|
514
|
+
if (pidAlive(pid) && processIdentity(pid) === identity) throw new Error(`service ${id}: 停止超时,进程仍存活`)
|
|
515
|
+
if (pidAlive(pid) && !processIdentity(pid)) throw new Error(`service ${id}: 无法确认进程是否退出`)
|
|
516
|
+
}
|
|
517
|
+
await this._locked(async () => {
|
|
518
|
+
const services = await this._loadRegistry()
|
|
519
|
+
if (!services[id] || services[id].pid !== pid || services[id].startedAt !== row.startedAt) {
|
|
520
|
+
throw new Error(`service ${id}: 停止期间记录已变更`)
|
|
521
|
+
}
|
|
522
|
+
services[id] = { ...services[id], state: 'stopped', pid: null, stoppedAt: new Date().toISOString() }
|
|
523
|
+
await this._saveRegistry(services)
|
|
524
|
+
})
|
|
525
|
+
if (absent(pid)) removeReceipt(this.discoveryPath, id)
|
|
526
|
+
this._owned.delete(id)
|
|
527
|
+
this.log(`■ ${id} 已停止(pid ${pid})`)
|
|
528
|
+
return { id, state: 'stopped', stopped: true }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 记录各服务最近一次 up 的原始 spec,供守护按原样重启。 */
|
|
532
|
+
rememberSpec(spec) {
|
|
533
|
+
this._lastSpecs.set(spec.id, normalizeSpec(spec))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** keepAlive 守护:周期巡检,进程死亡按原规格原端口重启(限连续次数)。 */
|
|
537
|
+
startGuard({ intervalMs = 5000, maxConsecutive = 3 } = {}) {
|
|
538
|
+
if (this._guardTimer) return
|
|
539
|
+
this._guardTimer = setInterval(() => {
|
|
540
|
+
if (this._stopping || this._sweeping) return
|
|
541
|
+
this._sweeping = true
|
|
542
|
+
this._sweepOnce(maxConsecutive).finally(() => { this._sweeping = false })
|
|
543
|
+
}, intervalMs)
|
|
544
|
+
this._guardTimer.unref?.()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async _markError(row, message) {
|
|
548
|
+
await this._locked(async () => {
|
|
549
|
+
const cur = await this._loadRegistry()
|
|
550
|
+
const current = cur[row.id]
|
|
551
|
+
if (!current || current.state !== 'running' || current.pid !== row.pid || current.startedAt !== row.startedAt) return
|
|
552
|
+
cur[row.id] = { ...current, state: 'error', error: message }
|
|
553
|
+
await this._saveRegistry(cur)
|
|
554
|
+
})
|
|
555
|
+
this.log(`✗ ${row.id} ${message}`)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async _sweepOnce(maxConsecutive) {
|
|
559
|
+
try {
|
|
560
|
+
const services = await this._loadRegistry()
|
|
561
|
+
for (const row of Object.values(services)) {
|
|
562
|
+
if (!row.keepAlive || row.state !== 'running' || !this._owned.has(row.id)) continue
|
|
563
|
+
if (pidAlive(row.pid)) {
|
|
564
|
+
this._guardFailures.delete(row.id)
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
// Root death alone is not proof its descendants are gone. Serialize the
|
|
568
|
+
// decision with up/stop, then require the declared ports to be free
|
|
569
|
+
// before dropping the receipt or starting a replacement.
|
|
570
|
+
const prev = this._upLocks.get(row.id) || Promise.resolve()
|
|
571
|
+
const transition = prev.catch(() => {}).then(async () => {
|
|
572
|
+
if (this._stopping) return
|
|
573
|
+
const owned = this._owned.get(row.id)
|
|
574
|
+
if (!owned || owned.pid !== row.pid || !absent(owned.pid)) return
|
|
575
|
+
if (!(await waitReceiptResolvable({ pid: owned.pid, ports: row.ports }, row.ports))) {
|
|
576
|
+
await this._markError(row, '进程已退出但声明端口仍被占用,拒绝自动重启')
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
removeReceipt(this.discoveryPath, row.id)
|
|
580
|
+
this._owned.delete(row.id)
|
|
581
|
+
const failures = this._guardFailures.get(row.id) || 0
|
|
582
|
+
const spec = this._lastSpecs.get(row.id)
|
|
583
|
+
if (!spec) { await this._markError(row, '缺少重启规格,拒绝自动重启'); return }
|
|
584
|
+
if (failures >= maxConsecutive) { await this._markError(row, '连续重启次数超限,停止自动重启'); return }
|
|
585
|
+
this._guardFailures.set(row.id, failures + 1)
|
|
586
|
+
try {
|
|
587
|
+
await this._upOnce(spec, {})
|
|
588
|
+
this.log(`↻ ${row.id} 已自动重启`)
|
|
589
|
+
} catch (err) {
|
|
590
|
+
await this._markError(row, `自动重启失败: ${err.message}`)
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
this._upLocks.set(row.id, transition.catch(() => {}))
|
|
594
|
+
await transition
|
|
595
|
+
}
|
|
596
|
+
} catch { /* 巡检一轮失败不致命,下轮再来 */ }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async shutdown({ stopAll = false } = {}) {
|
|
600
|
+
this._stopping = true
|
|
601
|
+
if (this._guardTimer) { clearInterval(this._guardTimer); this._guardTimer = null }
|
|
602
|
+
if (!stopAll) return { ok: true, services: [], errors: [] }
|
|
603
|
+
const services = await this._loadRegistry()
|
|
604
|
+
const results = [], errors = []
|
|
605
|
+
for (const row of Object.values(services)) {
|
|
606
|
+
if (row.state === 'running') {
|
|
607
|
+
try {
|
|
608
|
+
const result = await this.stop(row.id)
|
|
609
|
+
results.push(result)
|
|
610
|
+
if (!result.stopped) errors.push({ id: row.id, error: result.reason || 'not stopped' })
|
|
611
|
+
} catch (err) {
|
|
612
|
+
errors.push({ id: row.id, error: err.message })
|
|
613
|
+
this.log(`✗ 停止 ${row.id} 失败: ${err.message}`)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { ok: errors.length === 0, services: results, errors }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/* ---------- 结构化命令投递 ---------- */
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* commands: [{op:'up', service:{...spec}} | {op:'stop', id}]
|
|
624
|
+
* up 按 requires 闭包拓扑排序;已健康的服务幂等跳过。
|
|
625
|
+
*/
|
|
626
|
+
async apply(commands, { waitMs } = {}) {
|
|
627
|
+
if (!Array.isArray(commands)) throw new Error('apply: commands 必须是数组')
|
|
628
|
+
const bad = commands.filter((c) => !c || !['up', 'stop'].includes(c.op))
|
|
629
|
+
if (bad.length) throw new Error(`apply: 未知命令 op: ${bad.map((c) => c?.op).join(', ')}`)
|
|
630
|
+
|
|
631
|
+
const byId = new Map()
|
|
632
|
+
for (const c of commands) {
|
|
633
|
+
if (c.op !== 'up') continue
|
|
634
|
+
const spec = normalizeSpec(c.service)
|
|
635
|
+
byId.set(spec.id, spec)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 拓扑排序(requires 闭包;指向本批之外的服务视为已由别处管理,跳过)
|
|
639
|
+
const ordered = []
|
|
640
|
+
const visiting = new Set()
|
|
641
|
+
const done = new Set()
|
|
642
|
+
const visit = (id, chain = []) => {
|
|
643
|
+
if (done.has(id)) return
|
|
644
|
+
if (visiting.has(id)) throw new Error(`apply: requires 成环: ${[...chain, id].join(' → ')}`)
|
|
645
|
+
const spec = byId.get(id)
|
|
646
|
+
if (!spec) return
|
|
647
|
+
visiting.add(id)
|
|
648
|
+
for (const dep of spec.requires || []) visit(dep, [...chain, id])
|
|
649
|
+
visiting.delete(id)
|
|
650
|
+
done.add(id)
|
|
651
|
+
ordered.push(spec)
|
|
652
|
+
}
|
|
653
|
+
for (const id of byId.keys()) visit(id)
|
|
654
|
+
|
|
655
|
+
const results = []
|
|
656
|
+
const errors = []
|
|
657
|
+
for (const spec of ordered) {
|
|
658
|
+
this.rememberSpec(spec)
|
|
659
|
+
try {
|
|
660
|
+
results.push(await this.up(spec, { waitMs }))
|
|
661
|
+
} catch (err) {
|
|
662
|
+
errors.push({ id: spec.id, error: err.message })
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const cmd of commands.filter((c) => c.op === 'stop')) {
|
|
666
|
+
try {
|
|
667
|
+
const result = await this.stop(cmd.id)
|
|
668
|
+
results.push(result)
|
|
669
|
+
if (!result.stopped) errors.push({ id: cmd.id, error: result.reason || 'unknown service' })
|
|
670
|
+
} catch (err) {
|
|
671
|
+
errors.push({ id: cmd.id, error: err.message })
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return { ok: errors.length === 0, services: results, errors }
|
|
675
|
+
}
|
|
676
|
+
}
|