dsh-remote-agent 0.9.4 → 0.9.5
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/CHANGELOG.md +80 -0
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/lib/hub/index.js +75 -26
- package/lib/index.js +89 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,86 @@
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.9.5] - 2026-10-20
|
|
13
|
+
|
|
14
|
+
> **紧急修复:0.9.4 装上后 DSH 报激活失败,插件显示"异常"。**
|
|
15
|
+
>
|
|
16
|
+
> ```
|
|
17
|
+
> webserver: duplicate upgrade route "/api/remote-agent/node"
|
|
18
|
+
> 1 entry did not activate remote-agent (dsh-remote-agent)
|
|
19
|
+
> ```
|
|
20
|
+
>
|
|
21
|
+
> 0.9.4 在**全新启动**的场景是好的(我验证过单次 apply 注册一次),
|
|
22
|
+
> 但**热重载会撞车** —— 而 DSH 在插件安装/升级后正是靠热重载生效的。
|
|
23
|
+
|
|
24
|
+
### 🔴 根因:我们的注册不可回收,而宿主不会帮我们回收
|
|
25
|
+
|
|
26
|
+
`webServer.registerUpgrade(path)` 在 DSH 里是**按 path 查表**的,同路径第二次
|
|
27
|
+
注册直接抛(组合级契约,它这么做是对的):
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
registerUpgrade(route) {
|
|
31
|
+
if (this.upgrades.has(route.path)) throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
|
|
32
|
+
this.upgrades.set(route.path, route)
|
|
33
|
+
return () => { this.upgrades.delete(route.path) }
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
而本插件的注册全部收集在 `__disposers` 里,通过 `applyHub()` 的返回值
|
|
38
|
+
(`disposeAll`)交给调用方保管 —— **但 DSH 不认识这个返回值**。热重载时它只
|
|
39
|
+
重新 `apply`,不会回头调我们的 disposer。于是第二代的注册压在第一代的残留上:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
第一代 apply → 注册 /api/remote-agent/node(表里有了)
|
|
43
|
+
DSH 热重载 → 直接再 apply(不 dispose)
|
|
44
|
+
第二代 apply → registerUpgrade(同一 path) → throw → 整个 entry 激活失败
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
0.9.3 及以前没暴露,是因为那时改完配置都靠"重启 DSH"(进程重来,表是新的);
|
|
48
|
+
0.9.4 是在插件管理页里安装/升级,走了热重载路径 —— 这是**场景差异,不是新引入的代码**。
|
|
49
|
+
|
|
50
|
+
### 修复
|
|
51
|
+
|
|
52
|
+
1. **apply 变成可重入**(`lib/index.js`):按**注册宿主**(`ctx.webServer` 服务
|
|
53
|
+
实例,读不到时退回 ctx)记住"当前这一代"的 dispose;重新 apply 时先自己把
|
|
54
|
+
上一代摘干净再装。**不指望宿主帮我们清理** —— 这是唯一在各种宿主行为下都
|
|
55
|
+
成立的修法。
|
|
56
|
+
2. **`disposeAll` 改成逆序**(`lib/hub/index.js`):此前是正序跑的,会先拆运行时
|
|
57
|
+
(`registry.dispose()`)再拆工具,工具的 disposer 可能碰到已销毁的 registry。
|
|
58
|
+
现在与 cordis 的"逆序释放"语义一致。
|
|
59
|
+
3. **独立监听加了 EADDRINUSE 有限重试**:重入时先 `close()` 旧监听、立刻用同一
|
|
60
|
+
端口重新 `listen`,在 Windows 上经常还拿不到端口(close 是异步的)。重试
|
|
61
|
+
4 次(200/400/600ms 退避),真被别的进程占用仍会如实报错。
|
|
62
|
+
4. **`handles.dispose`**:apply 的返回值现在带一个顶层 `dispose()`,任何愿意调
|
|
63
|
+
它的宿主都能正确清理。
|
|
64
|
+
|
|
65
|
+
### 为什么 CI 没拦住(第三次同类)
|
|
66
|
+
|
|
67
|
+
仓库里所有测试桩都**比宿主宽松**:`hub-worker-e2e.test.js` 的 `makeCtx()` 里
|
|
68
|
+
`register()` 是 `table.set()`(同名静默覆盖)、`registerUpgrade()` 只是再挂一个
|
|
69
|
+
`server.on('upgrade')`。真宿主会抛,桩不会 —— **桩比宿主宽松 = 假绿灯**。
|
|
70
|
+
|
|
71
|
+
新增 `test/webserver-contract.test.js`:照宿主源码造一个**重复即抛**的严格 ctx,
|
|
72
|
+
钉住四条契约:
|
|
73
|
+
|
|
74
|
+
- 单次 apply 只注册一条 upgrade 路由
|
|
75
|
+
- **同一 ctx 重复 apply 不抛**(就是本次的事故回归)
|
|
76
|
+
- apply → dispose → apply 可反复进行,且旧世代的 dispose 不得影响新世代
|
|
77
|
+
- 重入后**独立监听仍能绑回同一端口**(热重载不能把端点变成永久不可用)
|
|
78
|
+
|
|
79
|
+
### 升级须知
|
|
80
|
+
|
|
81
|
+
- 装完请在插件页把开关**关一次再打开**(或重启 DSH),让热重载重新加载新代码。
|
|
82
|
+
- 如果你之前点了「生成口令」,副机要用新口令重新配对。
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
packaging 4 项
|
|
86
|
+
doc-drift 123/123
|
|
87
|
+
diag:inject 11 项
|
|
88
|
+
tests 987(985 pass / 0 fail / 2 skip)
|
|
89
|
+
lint 干净
|
|
90
|
+
```
|
|
91
|
+
|
|
12
92
|
## [0.9.4] - 2026-10-20
|
|
13
93
|
|
|
14
94
|
> **0.9.3 审查的 19 项全部修完。** 0.9.3 把架构换对了(插件自己开 19387),
|
package/README.en.md
CHANGED
|
@@ -37,7 +37,7 @@ explicitly pull them.
|
|
|
37
37
|
| **Platforms** | Windows (fully verified) · Linux / macOS (CI covered) |
|
|
38
38
|
| **Dependencies** | **Zero external npm dependencies** —— `node:` built-ins only |
|
|
39
39
|
| **Node** | `^22.19.0 \|\| >=24.0.0` |
|
|
40
|
-
| **Tests** |
|
|
40
|
+
| **Tests** | 987 (985 pass / 2 skip) · 123 doc-drift assertions · eslint strict |
|
|
41
41
|
|
|
42
42
|
**Web panel**(sidebar → "远程同事" / Remote co-worker):node list with CPU /
|
|
43
43
|
memory badges, task dispatch, task states, result summaries, workspace changes,
|
package/README.md
CHANGED
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
|
|
31
31
|
| | |
|
|
32
32
|
|---|---|
|
|
33
|
-
| **状态** | 0.9.
|
|
33
|
+
| **状态** | 0.9.5 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
|
|
34
34
|
| **DSH** | 桌面版 / CLI,0.2.0-rc.2 验证 |
|
|
35
35
|
| **平台** | Windows(全量验证)· Linux / macOS(CI 覆盖)|
|
|
36
36
|
| **依赖** | **零外部 npm 依赖** —— 只用 `node:` 内置模块 |
|
|
37
37
|
| **Node** | `^22.19.0 \|\| >=24.0.0` |
|
|
38
|
-
| **测试** |
|
|
38
|
+
| **测试** | 987 项(985 pass / 2 skip)· 123 条文档漂移断言 · eslint 严格档 |
|
|
39
39
|
|
|
40
40
|
**Web 面板**(侧栏「远程同事」):节点列表(含 CPU/内存徽章)、派发任务、
|
|
41
41
|
任务状态、结果摘要、工作区变更、成品清单、日志尾巴、**环境体检**、
|
package/lib/hub/index.js
CHANGED
|
@@ -597,32 +597,65 @@ export function applyHub(ctx, config = {}) {
|
|
|
597
597
|
|
|
598
598
|
let disposed = false
|
|
599
599
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
600
|
+
/**
|
|
601
|
+
* 起监听,并对 **EADDRINUSE 做有限重试**。
|
|
602
|
+
*
|
|
603
|
+
* 为什么需要:0.9.5 起 apply 可重入(同一 ctx 重新 apply 时先摘上一代)。
|
|
604
|
+
* 摘上一代会 `server.close()`,而**紧接着用同一个端口重新 listen** 在
|
|
605
|
+
* Windows 上经常还会拿到 EADDRINUSE(close 是异步完成的,端口没这么快
|
|
606
|
+
* 释放)。没有重试的话,"热重载"就会把端点变成永久不可用 —— 看起来和
|
|
607
|
+
* "插件坏了"一样,但其实是可自愈的。
|
|
608
|
+
*
|
|
609
|
+
* 只重试 4 次(200/400/600ms):真的被别的进程占了就该报错,不能无限拖。
|
|
610
|
+
*/
|
|
611
|
+
const startWithRetry = async (attempt = 1) => {
|
|
612
|
+
try {
|
|
613
|
+
return await startListenServer({
|
|
614
|
+
host: picked.host,
|
|
615
|
+
port,
|
|
616
|
+
path: cfg.nodePath,
|
|
617
|
+
healthPath: cfg.healthPath,
|
|
618
|
+
// 函数形态:按调用者身份分级(匿名只见 `{ ok: true }`)
|
|
619
|
+
healthBody: (req) => healthPayload(req, 'listen-server'),
|
|
620
|
+
onUpgrade: (conn, req) => {
|
|
621
|
+
// 与 webServer 那条路完全相同的鉴权与交接逻辑(一处不能少:
|
|
622
|
+
// token 校验是这条通道唯一的口令,跳过就是一个无鉴权入口)。
|
|
623
|
+
if (cfg.nodeToken !== '') {
|
|
624
|
+
const token = req.headers['x-dsh-token']
|
|
625
|
+
if (!safeEqual(token, cfg.nodeToken)) {
|
|
626
|
+
log('warn', 'node upgrade rejected: bad token (listen-server)', { remote: remoteAddress(req) })
|
|
627
|
+
try {
|
|
628
|
+
conn.close(1008, 'bad token')
|
|
629
|
+
} catch {
|
|
630
|
+
/* 已关 */
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
618
634
|
}
|
|
619
|
-
|
|
620
|
-
}
|
|
635
|
+
registry.attachDirect(conn, { remote: remoteAddress(req) })
|
|
636
|
+
},
|
|
637
|
+
log,
|
|
638
|
+
})
|
|
639
|
+
} catch (err) {
|
|
640
|
+
const code = String(err?.code ?? '')
|
|
641
|
+
const inUse = code === 'EADDRINUSE' || /EADDRINUSE/.test(String(err?.message ?? ''))
|
|
642
|
+
if (inUse && attempt < 4 && !disposed) {
|
|
643
|
+
log('warn', 'listen: port still held, retrying (previous generation closing)', {
|
|
644
|
+
host: picked.host,
|
|
645
|
+
port,
|
|
646
|
+
attempt,
|
|
647
|
+
})
|
|
648
|
+
await new Promise((r) => {
|
|
649
|
+
const t = setTimeout(r, 200 * attempt)
|
|
650
|
+
if (t.unref) t.unref()
|
|
651
|
+
})
|
|
652
|
+
return startWithRetry(attempt + 1)
|
|
621
653
|
}
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
654
|
+
throw err
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
startWithRetry()
|
|
626
659
|
.then((h) => {
|
|
627
660
|
if (disposed) {
|
|
628
661
|
// 竞态:disposer 先跑了(插件卸载/HMR),把刚起来的 server 关掉
|
|
@@ -851,8 +884,24 @@ export function applyHub(ctx, config = {}) {
|
|
|
851
884
|
return {
|
|
852
885
|
registry,
|
|
853
886
|
pulls,
|
|
854
|
-
|
|
855
|
-
|
|
887
|
+
/**
|
|
888
|
+
* 由调用方保管;DSH 卸载插件 / 我们自己在重入前清理时用。
|
|
889
|
+
*
|
|
890
|
+
* ⚠ **必须逆序**(最后注册的先摘)。`__reg` 的顺序是「运行时 effect 在最先、
|
|
891
|
+
* 路由/工具/监听在其后」,而运行时那个 disposer 会 `registry.dispose()` /
|
|
892
|
+
* `pulls.dispose()` —— 先拆它再去拆工具,工具的 disposer 就可能碰到已销毁的
|
|
893
|
+
* registry(0.9.5 修重入时发现这里原来是正序跑的,与 cordis 的"逆序释放"
|
|
894
|
+
* 语义和本文件的注释都不一致)。
|
|
895
|
+
*/
|
|
896
|
+
disposeAll: () => {
|
|
897
|
+
for (let i = __disposers.length - 1; i >= 0; i -= 1) {
|
|
898
|
+
try {
|
|
899
|
+
__disposers[i]()
|
|
900
|
+
} catch {
|
|
901
|
+
/* 已清 */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
},
|
|
856
905
|
}
|
|
857
906
|
}
|
|
858
907
|
|
package/lib/index.js
CHANGED
|
@@ -138,6 +138,31 @@ async function applyWorkerBranch(ctx, config, log) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* 每个"注册宿主"上当前这一代的清理函数。
|
|
143
|
+
*
|
|
144
|
+
* 键选 **`ctx.webServer` 服务实例**,不是 `ctx`:
|
|
145
|
+
* 撞车的是**宿主的路由表**(`webServer.upgrades` 这张 Map)。同一个 ctx 当然
|
|
146
|
+
* 共用同一张表,但 DSH 热重载时也可能给每代一个新的子 ctx —— 那时按 ctx 记
|
|
147
|
+
* 就会漏掉,照样撞车。按服务实例记才对应"真正会冲突的那个东西"。
|
|
148
|
+
*
|
|
149
|
+
* 读不到 `ctx.webServer`(副机 profile 的 `inject: []` 一读就抛)时退回 ctx,
|
|
150
|
+
* 这条兜底只是让字典有键,不影响正确性。
|
|
151
|
+
*
|
|
152
|
+
* 用 WeakMap:既不阻止 GC,也不会把多个 profile / 多个 host 实例串在一起。
|
|
153
|
+
*/
|
|
154
|
+
const GENERATIONS = new WeakMap()
|
|
155
|
+
|
|
156
|
+
/** 取"注册宿主"作为字典键。读服务可能抛(无 inject),所以必须 try。 */
|
|
157
|
+
function generationKey(ctx) {
|
|
158
|
+
try {
|
|
159
|
+
const ws = ctx && ctx.webServer
|
|
160
|
+
return ws !== undefined && ws !== null ? ws : ctx
|
|
161
|
+
} catch {
|
|
162
|
+
return ctx
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
/**
|
|
142
167
|
* DSH 插件入口。
|
|
143
168
|
* @param {object} ctx Cordis 上下文
|
|
@@ -188,6 +213,64 @@ export function apply(ctx, config = {}) {
|
|
|
188
213
|
}
|
|
189
214
|
const handles = { role, hub: undefined, worker: undefined, unconfigured: unconfigured === true }
|
|
190
215
|
|
|
216
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
217
|
+
// 可重入:同一个 ctx 上只允许存在**一代**注册(0.9.5 修 duplicate 激活失败)
|
|
218
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
219
|
+
//
|
|
220
|
+
// 0.9.4 装上后 DSH 报:
|
|
221
|
+
//
|
|
222
|
+
// webserver: duplicate upgrade route "/api/remote-agent/node"
|
|
223
|
+
// 1 entry did not activate remote-agent (dsh-remote-agent)
|
|
224
|
+
//
|
|
225
|
+
// 根因不是"注册了两次"写错,而是**我们的注册不可回收**:
|
|
226
|
+
//
|
|
227
|
+
// · DSH 的 `webServer.registerUpgrade(path)` 是**按 path 查表**的,
|
|
228
|
+
// 同路径第二次注册直接抛(这是它的组合级契约,合理):
|
|
229
|
+
// if (this.upgrades.has(route.path)) throw new Error('webserver: duplicate upgrade route ...')
|
|
230
|
+
// · 我们的注册全部收集在 `__disposers` 里、由 `applyHub()` 的返回值
|
|
231
|
+
// `disposeAll` 交给调用方保管 —— 但 **DSH 不认识这个返回值**,
|
|
232
|
+
// 热重载/安装后 reload 时它只会重新 `apply`,不会回头调我们的 disposer。
|
|
233
|
+
// · 于是第二代在第一代的残留上注册 → 撞车 → 整个 entry 激活失败。
|
|
234
|
+
//
|
|
235
|
+
// 为什么以前没暴露:只有"同一个 ctx 上重新 apply"才触发。DSH 在
|
|
236
|
+
// 插件安装/升级后会热重载,这正是用户装 0.9.4 时踩到的那一步。
|
|
237
|
+
//
|
|
238
|
+
// 修法:**不指望宿主帮我们清理**。按 ctx 记住上一代的 dispose,重新
|
|
239
|
+
// apply 时先自己摘干净再装。这样无论宿主是否调用 disposer,都不会重复注册。
|
|
240
|
+
const genKey = generationKey(ctx)
|
|
241
|
+
const prevDispose = GENERATIONS.get(genKey)
|
|
242
|
+
if (typeof prevDispose === 'function') {
|
|
243
|
+
try {
|
|
244
|
+
prevDispose()
|
|
245
|
+
log('info', 're-apply on the same host: previous generation disposed first')
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log('warn', 'previous generation dispose failed (continuing)', {
|
|
248
|
+
error: String(err?.message ?? err),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let released = false
|
|
254
|
+
const dispose = () => {
|
|
255
|
+
if (released) return
|
|
256
|
+
released = true
|
|
257
|
+
// 只摘自己:新一代可能已经顶替了这一格,别把它的注册也拆了
|
|
258
|
+
if (GENERATIONS.get(genKey) === dispose) GENERATIONS.delete(genKey)
|
|
259
|
+
// 顺序:hub 的路由/监听 → setup 路由 → worker 拨号。
|
|
260
|
+
// 每一步独立兜底:一个失败不能让后面的漏摘(否则又变成"摘一半")。
|
|
261
|
+
try { handles.hub?.disposeAll?.() } catch (err) {
|
|
262
|
+
log('warn', 'hub dispose failed', { error: String(err?.message ?? err) })
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(handles.setup)) {
|
|
265
|
+
for (const d of handles.setup) {
|
|
266
|
+
try { if (typeof d === 'function') d() } catch { /* 已摘 */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try { if (typeof handles.worker === 'function') handles.worker() } catch { /* 已摘 */ }
|
|
270
|
+
}
|
|
271
|
+
GENERATIONS.set(genKey, dispose)
|
|
272
|
+
handles.dispose = dispose
|
|
273
|
+
|
|
191
274
|
// ── 未配置:什么都不注册 ────────────────────────────────────────────────
|
|
192
275
|
//
|
|
193
276
|
// 为什么要有这个分支:首次安装时**插件无法知道这台机器是主机还是副机** ——
|
|
@@ -252,6 +335,12 @@ export function apply(ctx, config = {}) {
|
|
|
252
335
|
// 动态 import 是异步的;不 await —— apply 本身是同步契约。
|
|
253
336
|
// 失败已在 applyWorkerBranch 内部记日志,不让 host 因副机缺件而起不来。
|
|
254
337
|
void applyWorkerBranch(ctx, config, log).then((h) => {
|
|
338
|
+
// 竞态:这一代已经被 dispose(热重载/卸载)了,把刚挂上的 worker 立刻摘掉。
|
|
339
|
+
// 否则它会留在后台继续拨号 —— 一份回收不掉的僵尸连接。
|
|
340
|
+
if (released) {
|
|
341
|
+
try { if (typeof h === 'function') h() } catch { /* 已摘 */ }
|
|
342
|
+
return
|
|
343
|
+
}
|
|
255
344
|
handles.worker = h
|
|
256
345
|
})
|
|
257
346
|
}
|