feat(overlay): 覆盖网络线 序㊾ —— 探针观测面改「两台中继并集」(附 序㊽ 源码/文档补提交)

序㊾(本棒):
- scripts/overlay-probe.cjs:OBS-01 / OBS-08 / OBS-09 的数据源由「只读 47 中继」
  改为「按两台中继取并集」,消除 worker 归属漂移时的假红 / 假 SKIP
  · endpoints 以 network:hostId:port 为键合并,online 取「或」、localPort 取在线那一侧
  · used 按 network/hostId 去重计数(不求和,避免凭空放大在册数)
  · localPort 属中继机回环落点 ⇒ 按归属分机探活(106 侧落点由 106 机上探)
  · derived(OBS-11)保持 47 视角;阈值与判据一律未放宽
  · OBS-16 计数约束:对 47 /status 的读取仍为三次、Δ 只取 47 的 counters;
    对端 106 的采样为独立一次,落在第三次采样之后,不进 (status2, status3] 门窗口
  · 新增 --peer-status-fixture(并集的对端那一半)与「并集不可取证」强制留痕
- 交接单《覆盖网络-序45-低熵块治理-测熵与实现》§16 全节(§8 前前缀逐字未变)
- 参数表 §11.16 补记(§10 现算指纹未变,值格未动)

附(前几棒已完成并已部署、但尚未入仓的源码 / 文档):
- src/net/relay/content/*.ts、src/net/relay/index.ts、main.ts:块级寻址 C 域分离
- src/supervisor/orchestrator.ts、src/worker/agent.ts:日志采集与巡检(方案 C)
- test/overlay-content.test.mjs:随附用例(npm test = 200 pass / 0 fail / 1 skipped,Node 22)
- scripts/dshlog.mjs(跨机日志取证)、scripts/overlay-entropy.cjs(熵探针)
- dsh-server-docs/04-调整方案/129、133;INDEX.md / docs-manifest.json / 交接单 README 登记
This commit is contained in:
admin committed 2026-09-19 05:35:35 +08:00
1 parent 45b4999d24
commit d2ef362a98
20 files changed
+2998 -183

No files matched your search

+83 -8
View File
@@ -343,6 +343,28 @@ export class LocalSpawner implements Spawner {
notes: [],
}
/**
* 覆盖网络线 序 ㊽:**本轮认领的探活全部落定**之后的回调。
*
* ## 为什么需要它(这正是一个实测缺陷的修法)
* 认领来的存量实例**刻意不进 `mains`**(文件头 序 ㉕ 的边界:`launchToken` 不可恢复),
* 而 `listUserInstances()` 的口径**就是 `mains`** ⇒ 上一进程遗留的实例监听端口**没有任何人**
* 会向 relay 重新声明一遍。实测症状(2026-09-19):106 的实例 `:21001` 进程健在、
* `[rehydrate] probe OK` 也打了,但两台中继的端点表里都没有它(47 侧只留一条
* `w-106:19000 online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。
*
* ⇒ 由 **worker agent** 接这个回调,把 `adoptedInstancePorts()` 并进对账口径(⛔ 不改认领语义、
* ⛔ 不把认领实例写进 `mains`):端口一落定就登记,**不必等 20 s 对账节拍**。
* ⚠️ 缺省 `undefined` ⇒ 行为与改造前**逐字一致**(其他装配点不受影响)。
*/
onRehydrateSettled: (() => void) | undefined
/** 序 ㊽:本轮仍在途的探活条数。探活是**异步**的(socket 事件)⇒ ⛔ 不能只看 `schedule` 的基例。 */
private pendingProbes = 0
/** 序 ㊽:本轮 `schedule` 已走完(不会再产生新探活)。`true` + `pendingProbes === 0` ⇒ 落定。 */
private rehydrateScheduled = false
constructor(
private readonly config: ServerConfig,
/** Resolve the user's own API key (decrypted); null = user has none. */
@@ -1411,16 +1433,62 @@ export class LocalSpawner implements Spawner {
return { ...this.rehydrate, notes: [...this.rehydrate.notes] }
}
/**
* 覆盖网络线 序 ㊽:**已认领且探活通过**的实例监听端口(worker agent 的对账口径之一)。
*
* 与 {@link listUserInstances} 的关系 = **互补,不合并**:那个的口径是"本进程 launch 过的"
* (`mains`),这个是"本进程**接管**的"(`adopted`)。认领来的实例没有 `launchToken`、
* 也进不了 `status(userId)`(文件头 序 ㉕ 的客观边界,⛔ 未改),但"**这个端口在听、且归本
* worker 管**"与实例身份无关 ⇒ 该登记给 relay 就得登记(否则跨机代理查不到落点)。
*
* ⚠️ 只报 `alive === true` 的:探活没过的那条已被 `probeAdopted` 按旧行为停掉了,
* 报出去只会让上游去登记一个死口。⛔ 未出生的探活(`alive === undefined`)同样不报。
*/
adoptedInstancePorts(): number[] {
const out: number[] = []
for (const rec of this.adopted.values()) {
if (rec.alive === true && rec.info.port !== undefined) out.push(rec.info.port)
}
return out.sort((a, b) => a - b)
}
/**
* 序 ㊽:本轮 `schedule` 收尾 —— ⚠️ **探活可能还没落定**(异步)⇒ 只置标志,
* 由 {@link probeAdopted} 的最后一条补射(见 {@link maybeRehydrateSettled})。
*/
private settleRehydrate(): void {
this.rehydrateScheduled = true
this.maybeRehydrateSettled()
}
/** 序 ㊽:`schedule` 走完 + 在途探活归零 ⇒ **恰好通知一次**(幂等;回调抛错⛔不许拖垮编排器)。 */
private maybeRehydrateSettled(): void {
if (!this.rehydrateScheduled || this.pendingProbes > 0) return
this.rehydrateScheduled = false
try {
this.onRehydrateSettled?.()
} catch (err) {
process.stderr.write(
`[rehydrate] ⚠️ 落定回调抛错(已吞,不阻断):${err instanceof Error ? err.message : String(err)}\n`,
)
}
}
private rehydrateAdoptedScopes(): void {
// 序 ㊽:新一轮认领起算 —— 上一轮的落定标志必须清掉(否则残留的 `true` 会让本轮提前通知)。
this.pendingProbes = 0
this.rehydrateScheduled = false
// ① 非 account 形态不产生 scope ⇒ 保持旧语义(此处是空操作)。
if (this.config.isolationMode !== 'account') {
this.cleanAllStaleScopes()
this.settleRehydrate()
return
}
const found = this.scanExistingScopes()
this.rehydrate.scanned = found.length
if (found.length === 0) {
process.stderr.write('[rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)\n')
this.settleRehydrate()
return
}
const perUid = new Map<number, number>()
@@ -1429,6 +1497,7 @@ export class LocalSpawner implements Spawner {
const schedule = (i: number): void => {
if (i >= found.length) {
process.stderr.write(`[rehydrate] summary ${JSON.stringify(this.rehydrateReport())}\n`)
this.settleRehydrate()
return
}
const t = setTimeout(() => {
@@ -1505,9 +1574,13 @@ export class LocalSpawner implements Spawner {
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
// 序 ㊽:这一条**不产生**探活 ⇒ 若此刻已是本轮最后一条,落定通知不能等它。
this.maybeRehydrateSettled()
return
}
let settled = false
// 序 ㊽:在途计数 —— 探活由 socket 事件驱动,`schedule` 的基例可能先于它跑完。
this.pendingProbes += 1
const sock = connect({ host: '127.0.0.1', port })
const done = (ok: boolean): void => {
if (settled) return
@@ -1521,15 +1594,17 @@ export class LocalSpawner implements Spawner {
if (ok) {
this.rehydrate.probeOk += 1
process.stderr.write(`[rehydrate] probe OK ${rec.unit} :${port}\n`)
return
} else {
this.rehydrate.probeFail += 1
const note = `probe-fail ${rec.unit} :${port}`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`)
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
}
this.rehydrate.probeFail += 1
const note = `probe-fail ${rec.unit} :${port}`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`)
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
this.pendingProbes -= 1
this.maybeRehydrateSettled()
}
sock.setTimeout(this.rehydrateProbeMs(), () => done(false))
sock.once('error', () => done(false))