@twinsearth/dsh-2xray 0.0.0-stage → 0.1.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/bin/README.md +27 -0
  5. package/cordis.patch.yml +37 -0
  6. package/docs/AC-0.md +60 -0
  7. package/docs/EVIDENCE.md +135 -0
  8. package/examples/config.downgrade.json +20 -0
  9. package/examples/config.example.json +22 -0
  10. package/examples/config.multi.downgrade.json +39 -0
  11. package/examples/config.multi.json +46 -0
  12. package/icon.svg +9 -0
  13. package/index.js +89 -0
  14. package/lib/binary.js +133 -0
  15. package/lib/config.js +401 -0
  16. package/lib/control/client.js +100 -0
  17. package/lib/control/grpc.js +121 -0
  18. package/lib/control/protobuf.js +143 -0
  19. package/lib/health.js +109 -0
  20. package/lib/log.js +20 -0
  21. package/lib/paths.js +65 -0
  22. package/lib/process.js +154 -0
  23. package/lib/route.js +299 -0
  24. package/lib/share-link.js +132 -0
  25. package/lib/supervisor.js +687 -0
  26. package/lib/tools.js +106 -0
  27. package/lib/xray-config.js +225 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +42 -4
  31. package/scripts/check-config.mjs +56 -0
  32. package/scripts/parse-share-link.mjs +23 -0
  33. package/scripts/probe-routing.mjs +66 -0
  34. package/scripts/release-check.mjs +104 -0
  35. package/test/binary.test.js +103 -0
  36. package/test/config.test.js +106 -0
  37. package/test/control.test.js +166 -0
  38. package/test/health.test.js +75 -0
  39. package/test/index.test.js +52 -0
  40. package/test/multi-node.test.js +111 -0
  41. package/test/route.test.js +158 -0
  42. package/test/share-link.test.js +113 -0
  43. package/test/supervisor.test.js +525 -0
  44. package/test/tools.test.js +88 -0
  45. package/test/xray-config.test.js +132 -0
@@ -0,0 +1,687 @@
1
+ import { join } from 'node:path'
2
+ import { rmSync } from 'node:fs'
3
+ import { buildXrayConfig } from './xray-config.js'
4
+ import { resolveBinary, probeVersion, tail } from './binary.js'
5
+ import { httpProxyProbe, portIsFree, waitForPort } from './health.js'
6
+ import { atomicWriteJson, sha256 } from './paths.js'
7
+
8
+ const CONFIG_REJECTED = 23
9
+
10
+ function safeTail(handle) {
11
+ try {
12
+ return handle?.stderrTail?.() ?? ''
13
+ } catch {
14
+ return ''
15
+ }
16
+ }
17
+
18
+ function formatHost(host) {
19
+ return host.includes(':') ? `[${host}]` : host
20
+ }
21
+
22
+ function withTimeout(promise, ms) {
23
+ return raceTimeout(
24
+ Promise.resolve(promise)
25
+ .then(() => true)
26
+ .catch(() => false),
27
+ ms,
28
+ false,
29
+ )
30
+ }
31
+
32
+ /** Race a promise against a timer that is always cleared, so tests exit fast. */
33
+ function raceTimeout(promise, ms, timeoutValue) {
34
+ let timer
35
+ return Promise.race([
36
+ promise,
37
+ new Promise((resolve) => {
38
+ timer = setTimeout(() => resolve(timeoutValue), ms)
39
+ timer.unref?.()
40
+ }),
41
+ ]).finally(() => clearTimeout(timer))
42
+ }
43
+
44
+ /**
45
+ * Owns the xray child lifecycle and the route decision for one plugin load.
46
+ *
47
+ * Invariants:
48
+ * A. The dispatcher is installed only after the inbound answered a TCP connect
49
+ * and (when enabled) a real proxied request; it is never pointed at a port
50
+ * that is not proven alive.
51
+ * B. Restore happens before terminate, so no window exists where the process
52
+ * is gone but the dispatcher still points at it.
53
+ * C. `stop()` waits for an in-flight `start()`, so a spawn cannot land after
54
+ * teardown and leak a process that owns the port.
55
+ * D. Every self-owned promise chain is caught; an unhandled rejection would
56
+ * dispose the whole harness process.
57
+ */
58
+ export class Supervisor {
59
+ #state = 'idle'
60
+ #reason
61
+ #detail
62
+ #generation = 0
63
+ #restarts = 0
64
+ #handle
65
+ #startTask
66
+ #stopRequested = false
67
+ #stopped = false
68
+ #abort = new AbortController()
69
+ #restartTimer
70
+ #healthTimer
71
+ #unhealthy = 0
72
+ #binary
73
+ #version
74
+ #configHash
75
+ #functionalProbe = 'pending'
76
+ #lastExit
77
+ #lastStdio = ''
78
+ #startedAt
79
+ #updatedAt = Date.now()
80
+ #snapshotCache
81
+ #publishChain = Promise.resolve()
82
+ #logger
83
+ #route
84
+ #launcher
85
+ #probes
86
+ #resolveBinaryFn
87
+ #control
88
+ #activeNode
89
+ #now
90
+
91
+ constructor({
92
+ config,
93
+ errors = [],
94
+ warnings = [],
95
+ launcher,
96
+ route,
97
+ logger,
98
+ dataDir,
99
+ statusPath,
100
+ probes = {},
101
+ resolveBinaryFn = resolveBinary,
102
+ control,
103
+ now = () => Date.now(),
104
+ }) {
105
+ this.config = config
106
+ this.errors = errors
107
+ this.warnings = warnings
108
+ this.#launcher = launcher
109
+ this.#route = route
110
+ this.#control = control
111
+ this.#activeNode = config.activeNode
112
+ this.#logger = logger
113
+ this.dataDir = dataDir
114
+ this.statusPath = statusPath ?? (dataDir ? join(dataDir, 'status.json') : undefined)
115
+ this.#probes = {
116
+ waitForPort,
117
+ httpProxyProbe,
118
+ portIsFree,
119
+ ...probes,
120
+ }
121
+ this.#resolveBinaryFn = resolveBinaryFn
122
+ this.#now = now
123
+ }
124
+
125
+ snapshot() {
126
+ if (!this.#snapshotCache) this.#snapshotCache = this.#buildSnapshot()
127
+ return this.#snapshotCache
128
+ }
129
+
130
+ /** Wait until a predicate over the latest snapshot holds, for tests and tools. */
131
+ waitFor(predicate, { timeoutMs = 10_000, intervalMs = 20 } = {}) {
132
+ const immediate = predicate(this.snapshot())
133
+ if (immediate) return Promise.resolve(this.snapshot())
134
+ return new Promise((resolve, reject) => {
135
+ const started = this.#now()
136
+ const timer = setInterval(() => {
137
+ const snap = this.snapshot()
138
+ if (predicate(snap)) {
139
+ clearInterval(timer)
140
+ resolve(snap)
141
+ } else if (this.#now() - started > timeoutMs) {
142
+ clearInterval(timer)
143
+ reject(new Error(`waitFor timeout; state=${snap.state} reason=${snap.reason ?? 'none'}`))
144
+ }
145
+ }, intervalMs)
146
+ })
147
+ }
148
+
149
+ async start() {
150
+ if (this.#stopped) return this.snapshot()
151
+ if (this.#startTask) return this.#startTask
152
+ this.#startedAt ??= this.#now()
153
+ this.#startTask = this.#run()
154
+ .catch((error) => {
155
+ this.#setState('failed', 'internal-error', String(error))
156
+ })
157
+ .finally(() => {
158
+ this.#startTask = undefined
159
+ })
160
+ return this.#startTask
161
+ }
162
+
163
+ async stop() {
164
+ if (this.#stopped) return
165
+ this.#stopped = true
166
+ this.#stopRequested = true
167
+ this.#abort.abort()
168
+ this.#stopHealthLoop()
169
+ if (this.#restartTimer) {
170
+ clearTimeout(this.#restartTimer)
171
+ this.#restartTimer = undefined
172
+ }
173
+ try {
174
+ await this.#startTask
175
+ } catch {
176
+ // start() never rejects; belt and braces
177
+ }
178
+ // Invariant B: restore the dispatcher before the process disappears.
179
+ await this.#route.restore().catch((error) => this.#logger?.warn?.(`恢复路由失败:${String(error)}`))
180
+ const handle = this.#handle
181
+ this.#handle = undefined
182
+ if (handle) {
183
+ await handle.terminate().catch(() => {})
184
+ const exited = await withTimeout(handle.waitForExit(), Math.max(1000, this.config.lifecycle.stopGraceMs + 1000))
185
+ if (!exited) {
186
+ this.#logger?.warn?.('xray 子进程未在 stopGraceMs 内退出,交由服务/Job Object 兜底')
187
+ this.#lastStdio = safeTail(handle) || this.#lastStdio
188
+ }
189
+ }
190
+ this.#setState('stopped', 'disposed')
191
+ }
192
+
193
+ async #run() {
194
+ const generation = ++this.#generation
195
+ const signal = this.#abort.signal
196
+ const lifecycle = this.config.lifecycle
197
+
198
+ if (!this.config.enabled) {
199
+ this.#setState('idle', 'disabled')
200
+ return
201
+ }
202
+ if (this.errors.length > 0) {
203
+ this.#setState('failed', 'config-invalid', this.errors.map((error) => `${error.field}: ${error.message}`).join('; '))
204
+ return
205
+ }
206
+
207
+ this.#setState('resolving')
208
+ let binary
209
+ try {
210
+ binary = await this.#resolveBinaryFn({
211
+ config: this.config,
212
+ dataDir: this.dataDir,
213
+ launcher: this.#launcher,
214
+ })
215
+ } catch (error) {
216
+ this.#setState('failed', error.code ?? 'binary-missing', error.message)
217
+ return
218
+ }
219
+ this.#binary = binary
220
+ if (signal.aborted) return
221
+
222
+ const version = await probeVersion({
223
+ launcher: this.#launcher,
224
+ binaryPath: binary.path,
225
+ timeoutMs: 5000,
226
+ }).catch((error) => ({ ok: false, detail: String(error) }))
227
+ if (!version.ok) {
228
+ this.#setState('failed', 'binary-unusable', version.detail)
229
+ return
230
+ }
231
+ this.#version = version.version
232
+ if (signal.aborted) return
233
+
234
+ const xrayConfig = buildXrayConfig(this.config)
235
+ const text = JSON.stringify(xrayConfig, null, 2)
236
+ this.#configHash = sha256(text)
237
+ const configPath = join(this.dataDir, 'conf', 'xray-config.json')
238
+ try {
239
+ atomicWriteJson(configPath, xrayConfig, 0o600)
240
+ } catch (error) {
241
+ this.#setState('failed', 'config-write-failed', String(error))
242
+ return
243
+ }
244
+
245
+ const host = this.config.inbound.host
246
+ const port = this.config.inbound.port
247
+
248
+ if (!(await this.#probes.portIsFree(host, port))) {
249
+ this.#setState('failed', 'port-busy', `127.0.0.1:${port} 已被占用;不会静默改端口,请释放端口或修改 inbound.port`)
250
+ return
251
+ }
252
+ if (signal.aborted) return
253
+
254
+ const tested = await this.#runConfigTest(binary.path, configPath, generation)
255
+ if (tested.aborted) return
256
+ if (!tested.ok) {
257
+ this.#setState('failed', 'config-rejected', tail(tested.detail))
258
+ return
259
+ }
260
+ if (signal.aborted) return
261
+
262
+ this.#setState('starting')
263
+ let handle
264
+ try {
265
+ handle = this.#launcher.spawn({
266
+ argv: [binary.path, 'run', '-c', configPath],
267
+ cwd: this.dataDir,
268
+ graceMs: Math.max(500, lifecycle.stopGraceMs),
269
+ env: {},
270
+ })
271
+ } catch (error) {
272
+ this.#setState('failed', 'spawn-failed', String(error))
273
+ return
274
+ }
275
+ this.#handle = handle
276
+ this.#watch(handle, generation)
277
+
278
+ const readyOutcome = await Promise.race([
279
+ this.#probes
280
+ .waitForPort({
281
+ host,
282
+ port,
283
+ timeoutMs: lifecycle.startTimeoutMs,
284
+ signal,
285
+ })
286
+ .then((ok) => ({ ready: ok })),
287
+ // A process that exits before it ever listens must be classified by the
288
+ // exit watcher, not by a 15s port-poll timeout that would overwrite the
289
+ // real reason and swallow the retry.
290
+ handle.done.then(() => ({ exited: true })),
291
+ ])
292
+ if (signal.aborted) {
293
+ await this.#killHandle(handle)
294
+ return
295
+ }
296
+ if (readyOutcome.exited) return
297
+ if (!readyOutcome.ready) {
298
+ this.#lastStdio = safeTail(handle)
299
+ await this.#killHandle(handle)
300
+ if (generation !== this.#generation) return
301
+ this.#setState('failed', 'start-timeout', `端口 ${port} 在 ${lifecycle.startTimeoutMs}ms 内未监听;stderr: ${tail(this.#lastStdio)}`)
302
+ return
303
+ }
304
+
305
+ // Functional probe: proves the upstream path, not just the listener.
306
+ if (this.config.functionalProbe.enabled) {
307
+ const probe = await this.#probes.httpProxyProbe({
308
+ host,
309
+ port,
310
+ url: this.config.functionalProbe.url,
311
+ timeoutMs: this.config.functionalProbe.timeoutMs,
312
+ })
313
+ if (signal.aborted) {
314
+ await this.#killHandle(handle)
315
+ return
316
+ }
317
+ this.#functionalProbe = probe.ok ? 'pass' : `fail:${probe.status ?? probe.error ?? 'unknown'}`
318
+ if (!probe.ok) {
319
+ this.#lastStdio = safeTail(handle) || this.#lastStdio
320
+ await this.#killHandle(handle)
321
+ this.#setState(
322
+ 'failed',
323
+ 'functional-probe-failed',
324
+ `经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
325
+ '常见原因:uuid/flow/SNI/shortId/REALITY 公钥与服务端不一致,或远程不可达。' +
326
+ '确认链路正常后可设置 functionalProbe.enabled: false 只做 TCP 就绪检查。',
327
+ )
328
+ return
329
+ }
330
+ } else {
331
+ this.#functionalProbe = 'skipped'
332
+ }
333
+
334
+ if (this.config.takeover === 'off') {
335
+ this.#setState('ready', 'takeover-off', 'xray 已就绪,但 takeover=off,未接管全局流量')
336
+ } else {
337
+ try {
338
+ const scheme = this.config.inbound.protocol === 'socks' ? 'socks5' : 'http'
339
+ const result = await this.#route.install({
340
+ proxyUrl: `${scheme}://${formatHost(host)}:${port}`,
341
+ noProxy: this.config.noProxy,
342
+ force: this.config.takeover === 'force',
343
+ })
344
+ if (result.reason === 'external-proxy-active') {
345
+ this.#setState('degraded', 'external-proxy-active', '启动器已有代理策略;未接管。设置 takeover: force 可强制覆盖')
346
+ } else {
347
+ this.#setState('ready')
348
+ }
349
+ } catch (error) {
350
+ // The process is healthy; only routing failed. Keep it running so a
351
+ // fixed config or a later retry can recover without a spawn storm.
352
+ this.#setState('degraded', error.code ?? 'route-install-failed', error.message)
353
+ }
354
+ }
355
+
356
+ await this.#applyActiveNode()
357
+
358
+ if (signal.aborted) {
359
+ await this.#killHandle(handle)
360
+ return
361
+ }
362
+ this.#startHealthLoop(generation)
363
+ }
364
+
365
+ /**
366
+ * Pin the balancer to the configured node after (re)start. The override lives
367
+ * in the xray process, so a restart drops it and this re-applies it.
368
+ */
369
+ async #applyActiveNode() {
370
+ if (!this.config.control.enabled || this.config.nodes.length <= 1 || !this.#control) return
371
+ try {
372
+ await this.#control.getBalancerInfo()
373
+ await this.#control.overrideBalancerTarget(this.#activeNode)
374
+ } catch (error) {
375
+ const hint =
376
+ String(error.code) === '12'
377
+ ? '当前 xray 版本没有 RoutingService(本机实测 1.4.5 返回 UNIMPLEMENTED);请升级到 2024-09 之后的版本(v1.8.24+/v24.9+)'
378
+ : '出口暂由 balancer 的随机策略选择,切换工具不可用'
379
+ this.#logger?.warn?.(`控制面不可用(${error.code ?? error.message}):${hint}`)
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Runtime node switch through the control plane. The process, the local
385
+ * inbound and the installed dispatcher all stay untouched.
386
+ *
387
+ * @param {string} nodeRef - node id or outbound tag.
388
+ * @returns {Promise<string>} the outbound tag now pinned.
389
+ */
390
+ async switchNode(nodeRef) {
391
+ if (!this.config.control.enabled || !this.#control) {
392
+ throw new Error('control.enabled=false,无法运行时切换节点')
393
+ }
394
+ if (this.config.nodes.length <= 1) {
395
+ throw new Error('只有一个节点,没有可切换的 balancer')
396
+ }
397
+ const node = this.config.nodes.find((entry) => entry.id === nodeRef || entry.tag === nodeRef)
398
+ if (!node) {
399
+ throw new Error(`未知节点:${nodeRef};可用:${this.config.nodes.map((entry) => entry.tag).join(', ')}`)
400
+ }
401
+ await this.#control.overrideBalancerTarget(node.tag)
402
+ this.#activeNode = node.tag
403
+ this.#publish()
404
+ return node.tag
405
+ }
406
+
407
+ /**
408
+ * Read xray's cumulative outbound counters and fold them per node tag.
409
+ * StatsService counters are cumulative since process start, not a rate.
410
+ */
411
+ async queryStats({ reset = false } = {}) {
412
+ if (!this.#control) throw new Error('control.enabled=false,无统计可用')
413
+ const stats = await this.#control.queryStats({ patterns: ['outbound>>>'], reset })
414
+ const byTag = {}
415
+ for (const { name, value } of stats) {
416
+ const match = /^outbound>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(name)
417
+ if (!match) continue
418
+ byTag[match[1]] ??= { uplink: 0, downlink: 0 }
419
+ byTag[match[1]][match[2]] += value
420
+ }
421
+ return byTag
422
+ }
423
+
424
+ async #runConfigTest(binaryPath, configPath, generation) {
425
+ if (this.#abort.signal.aborted || generation !== this.#generation) return { aborted: true }
426
+ const outcome = await this.#runTestOnce(binaryPath, configPath)
427
+ if (this.#abort.signal.aborted || generation !== this.#generation) return { aborted: true }
428
+ return outcome
429
+ }
430
+
431
+ /**
432
+ * Spawn `xray run -test -c <file>` once and report its exit facts. Never
433
+ * binds a port: this is the safe pre-flight both startup and the
434
+ * `xray_validate_config` tool use.
435
+ */
436
+ async #runTestOnce(binaryPath, configPath) {
437
+ let handle
438
+ try {
439
+ handle = this.#launcher.spawn({
440
+ argv: [binaryPath, 'run', '-test', '-c', configPath],
441
+ cwd: this.dataDir ?? process.cwd(),
442
+ graceMs: 2000,
443
+ env: {},
444
+ })
445
+ } catch (error) {
446
+ return { ok: false, detail: String(error) }
447
+ }
448
+ const outcome = await raceTimeout(handle.done, 10_000, { timedOut: true })
449
+ if (outcome?.timedOut) {
450
+ await handle.terminate()
451
+ return { ok: false, detail: 'xray run -test 超时' }
452
+ }
453
+ return { ok: outcome.exitCode === 0, detail: `${handle.stdoutTail?.() ?? ''}\n${handle.stderrTail?.() ?? ''}` }
454
+ }
455
+
456
+ /**
457
+ * Validate the config built from the *current* settings without starting or
458
+ * restarting the proxy. Safe to call while `ready`.
459
+ *
460
+ * @returns {Promise<{ ok: boolean, detail: string, configHash: string }>}
461
+ */
462
+ async validateConfig() {
463
+ // Field-level problems are known before spawning anything; report them in
464
+ // the same shape so the model gets one contract for every failure.
465
+ if (this.errors?.length > 0) {
466
+ return {
467
+ ok: false,
468
+ reason: 'config-invalid',
469
+ detail: this.errors.map((error) => `${error.field}: ${error.message}`).join('; '),
470
+ configHash: null,
471
+ }
472
+ }
473
+ if (!this.dataDir) {
474
+ return { ok: false, reason: 'data-dir-unavailable', detail: '数据目录不可用,无法写入待校验的配置', configHash: null }
475
+ }
476
+
477
+ let binary
478
+ try {
479
+ binary =
480
+ this.#binary ??
481
+ (await this.#resolveBinaryFn({ config: this.config, dataDir: this.dataDir, launcher: this.#launcher }))
482
+ } catch (error) {
483
+ return {
484
+ ok: false,
485
+ reason: error.code ?? 'binary-missing',
486
+ detail: error.message,
487
+ configHash: null,
488
+ }
489
+ }
490
+
491
+ const built = buildXrayConfig(this.config)
492
+ const text = JSON.stringify(built, null, 2)
493
+ const file = join(this.dataDir, 'conf', `validate-${Date.now()}-${process.pid}.json`)
494
+ atomicWriteJson(file, built, 0o600)
495
+ try {
496
+ const outcome = await this.#runTestOnce(binary.path, file)
497
+ return { ok: outcome.ok, reason: outcome.ok ? null : 'config-rejected', detail: tail(outcome.detail), configHash: sha256(text) }
498
+ } finally {
499
+ try {
500
+ rmSync(file, { force: true })
501
+ } catch {
502
+ // best effort
503
+ }
504
+ }
505
+ }
506
+
507
+ #watch(handle, generation) {
508
+ handle.done
509
+ .then((outcome) => this.#onExit(handle, generation, outcome))
510
+ .catch((error) => this.#onExit(handle, generation, { error }))
511
+ // The watcher itself must never produce an unhandled rejection: that
512
+ // would dispose the whole harness process.
513
+ .catch((error) => this.#logger?.error?.(`处理 xray 退出时异常:${String(error)}`))
514
+ }
515
+
516
+ async #onExit(handle, generation, outcome) {
517
+ if (this.#stopRequested) return
518
+ if (generation !== this.#generation) return
519
+ this.#generation++
520
+ this.#stopHealthLoop()
521
+ if (this.#handle === handle) this.#handle = undefined
522
+ const stderr = safeTail(handle) || this.#lastStdio
523
+ this.#lastStdio = stderr
524
+ this.#lastExit = { exitCode: outcome?.exitCode ?? null, signal: outcome?.signal ?? null, at: this.#now() }
525
+ await this.#route.restore().catch(() => {})
526
+
527
+ if (outcome?.exitCode === CONFIG_REJECTED) {
528
+ this.#setState('failed', 'config-rejected', tail(stderr) || 'xray 以退出码 23 退出(配置错误),不会自动重启')
529
+ return
530
+ }
531
+ if (/bind|address already in use|only one usage of each socket address/i.test(stderr)) {
532
+ this.#setState('failed', 'port-busy', tail(stderr))
533
+ return
534
+ }
535
+ this.#scheduleRestart('crashed', stderr)
536
+ }
537
+
538
+ #scheduleRestart(reason, detail) {
539
+ if (this.#stopRequested || this.#stopped) return
540
+ const lifecycle = this.config.lifecycle
541
+ if (this.#restarts >= lifecycle.maxRestarts) {
542
+ this.#setState('failed', 'restart-exhausted', `连续重启 ${this.#restarts} 次仍未恢复;最后一次:${tail(detail)}`)
543
+ return
544
+ }
545
+ this.#restarts++
546
+ const base = Math.min(lifecycle.backoffMs * 2 ** (this.#restarts - 1), lifecycle.backoffMaxMs)
547
+ const delayMs = Math.round(base * (0.5 + Math.random() * 0.5))
548
+ this.#setState('backoff', reason, `第 ${this.#restarts}/${lifecycle.maxRestarts} 次重启,约 ${delayMs}ms 后`)
549
+ this.#restartTimer = setTimeout(() => {
550
+ this.#restartTimer = undefined
551
+ if (this.#stopRequested || this.#stopped) return
552
+ const startAgain = () => {
553
+ if (this.#stopRequested || this.#stopped || this.#startTask) return
554
+ this.start().catch((error) => this.#logger?.error?.(`重启 xray 失败:${String(error)}`))
555
+ }
556
+ // The failed run may still be unwinding (for example a start-timeout
557
+ // poll); wait for its task to settle before spawning a second process.
558
+ if (this.#startTask) this.#startTask.then(startAgain, startAgain)
559
+ else startAgain()
560
+ }, delayMs)
561
+ this.#restartTimer.unref?.()
562
+ }
563
+
564
+ #startHealthLoop(generation) {
565
+ const interval = this.config.lifecycle.healthIntervalMs
566
+ if (!(interval > 0)) return
567
+ this.#stopHealthLoop()
568
+ const tick = async () => {
569
+ this.#healthTimer = undefined
570
+ if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
571
+ try {
572
+ const ok = await this.#probes.waitForPort({
573
+ host: this.config.inbound.host,
574
+ port: this.config.inbound.port,
575
+ timeoutMs: Math.max(200, this.config.lifecycle.probeTimeoutMs),
576
+ signal: this.#abort.signal,
577
+ })
578
+ if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
579
+ if (ok) {
580
+ this.#unhealthy = 0
581
+ if (this.#state === 'degraded' && this.#reason === 'unhealthy') this.#setState('ready', 'health-recovered')
582
+ } else {
583
+ this.#unhealthy++
584
+ if (this.#unhealthy >= this.config.lifecycle.unhealthyThreshold) {
585
+ await this.#recycle('unhealthy')
586
+ return
587
+ }
588
+ this.#logger?.warn?.(`xray 健康探测失败 ${this.#unhealthy}/${this.config.lifecycle.unhealthyThreshold}`)
589
+ }
590
+ } catch (error) {
591
+ this.#logger?.warn?.(`健康探测异常:${String(error)}`)
592
+ }
593
+ if (this.#stopRequested || this.#stopped) return
594
+ this.#healthTimer = setTimeout(tick, interval)
595
+ this.#healthTimer.unref?.()
596
+ }
597
+ this.#healthTimer = setTimeout(tick, interval)
598
+ this.#healthTimer.unref?.()
599
+ }
600
+
601
+ #stopHealthLoop() {
602
+ if (this.#healthTimer) {
603
+ clearTimeout(this.#healthTimer)
604
+ this.#healthTimer = undefined
605
+ }
606
+ }
607
+
608
+ async #recycle(reason) {
609
+ const generation = ++this.#generation
610
+ this.#stopHealthLoop()
611
+ await this.#route.restore().catch(() => {})
612
+ const handle = this.#handle
613
+ this.#handle = undefined
614
+ this.#lastStdio = safeTail(handle) || this.#lastStdio
615
+ if (handle) await this.#killHandle(handle)
616
+ if (this.#stopRequested) return
617
+ this.#logger?.warn?.(`xray 连续健康探测失败,回收进程(generation=${generation})`)
618
+ this.#scheduleRestart(reason, this.#lastStdio)
619
+ }
620
+
621
+ async #killHandle(handle) {
622
+ if (!handle) return
623
+ await handle.terminate().catch(() => {})
624
+ await withTimeout(handle.waitForExit(), Math.max(1000, this.config.lifecycle.stopGraceMs))
625
+ if (this.#handle === handle) this.#handle = undefined
626
+ }
627
+
628
+ #setState(state, reason, detail) {
629
+ this.#state = state
630
+ this.#reason = reason
631
+ this.#detail = detail
632
+ this.#updatedAt = this.#now()
633
+ const line = `state=${state}${reason ? ` reason=${reason}` : ''}${detail ? ` detail=${detail}` : ''}`
634
+ if (state === 'failed') this.#logger?.error?.(line)
635
+ else if (state === 'degraded' || state === 'backoff') this.#logger?.warn?.(line)
636
+ else this.#logger?.info?.(line)
637
+ this.#publish()
638
+ }
639
+
640
+ #buildSnapshot() {
641
+ const handle = this.#handle
642
+ return {
643
+ plugin: 'dsh-2xray',
644
+ state: this.#state,
645
+ reason: this.#reason ?? null,
646
+ detail: this.#detail ?? null,
647
+ enabled: this.config.enabled,
648
+ takeover: this.config.takeover,
649
+ nodes: this.config.nodes.map((node) => ({ id: node.id, tag: node.tag, address: node.address, port: node.port })),
650
+ activeNode: this.#activeNode,
651
+ control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
652
+ generation: this.#generation,
653
+ restarts: this.#restarts,
654
+ port: this.config.inbound.port,
655
+ portSource: 'config',
656
+ binary: this.#binary
657
+ ? { path: this.#binary.path, source: this.#binary.source, version: this.#version ?? null }
658
+ : null,
659
+ process: {
660
+ pid: handle?.pid ?? null,
661
+ alive: Boolean(handle),
662
+ exitCode: this.#lastExit?.exitCode ?? null,
663
+ stderrTail: tail(this.#lastStdio, 2000),
664
+ },
665
+ route: this.#route?.snapshot?.() ?? { installed: false, mode: 'none' },
666
+ functionalProbe: this.#functionalProbe,
667
+ configHash: this.#configHash ?? null,
668
+ startedAt: this.#startedAt ?? null,
669
+ updatedAt: this.#updatedAt,
670
+ }
671
+ }
672
+
673
+ #publish() {
674
+ this.#snapshotCache = this.#buildSnapshot()
675
+ const snapshot = this.#snapshotCache
676
+ if (!this.statusPath) return
677
+ this.#publishChain = this.#publishChain
678
+ .then(() => {
679
+ try {
680
+ atomicWriteJson(this.statusPath, snapshot, 0o600)
681
+ } catch (error) {
682
+ this.#logger?.warn?.(`写入 status.json 失败:${String(error)}`)
683
+ }
684
+ })
685
+ .catch(() => {})
686
+ }
687
+ }