@steve02081504/fount-p2p 0.0.44 → 0.0.48

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.
@@ -0,0 +1,163 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { isHex64 } from '../core/hexIds.mjs'
4
+ import { sendToNodeLink } from '../transport/link_registry.mjs'
5
+ import { attachNodeScopeFeature } from '../transport/node_scope/features.mjs'
6
+
7
+ import { getNodeHash } from './identity.mjs'
8
+
9
+ const MAX_TIMEOUT = 30 * 60 * 1000
10
+
11
+ /**
12
+ * @typedef {{ challenge: string, requesterNodeHash: string, expiresAt: number }} NetworkChallenge 挑战
13
+ */
14
+
15
+ /**
16
+ * 创建有界的挑战服务;发送方身份一律取自已认证的网络入口,不信任载荷自称。
17
+ * @param {{nodeHash: string, send: (peer: string, action: string, payload: object) => Promise<boolean>, now?: () => number}} options 依赖
18
+ * @returns {object} 挑战服务
19
+ */
20
+ export function createNetworkVerificationService({ nodeHash, send, now = Date.now }) {
21
+ const requests = new Map()
22
+ const proofs = new Map()
23
+ /**
24
+ * 清理已过期的挑战记录。
25
+ * @returns {void} 无返回值
26
+ */
27
+ function prune() {
28
+ for (const [id, entry] of requests) if (entry.expiresAt + 60000 < now()) requests.delete(id)
29
+ }
30
+ /**
31
+ * 取挑战快照,并把已过期的 pending 收敛为 failed。
32
+ * @param {object} entry 挑战状态
33
+ * @returns {object} 快照
34
+ */
35
+ function snapshot(entry) {
36
+ if (entry.status === 'pending' && now() >= entry.expiresAt) entry.status = 'failed', entry.reason = 'timeout'
37
+ return { ...entry }
38
+ }
39
+ return {
40
+ /**
41
+ * 新建一个挑战:限幅超时、限制在途数量,返回待验证快照。
42
+ * @param {{ timeoutMs?: number }} [options] 超时选项
43
+ * @returns {object} 待验证快照
44
+ */
45
+ create({ timeoutMs = 300000 } = {}) {
46
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT) throw new Error('invalid timeoutMs')
47
+ prune()
48
+ if (requests.size >= 1024) throw new Error('too many verification requests')
49
+ const challenge = randomBytes(32).toString('hex')
50
+ const entry = { challenge, requesterNodeHash: nodeHash, expiresAt: now() + timeoutMs, status: 'pending' }
51
+ requests.set(challenge, entry)
52
+ return snapshot(entry)
53
+ },
54
+ /**
55
+ * 按挑战串读取快照;不存在返回 null。
56
+ * @param {string} challenge 挑战串
57
+ * @returns {object|null} 快照
58
+ */
59
+ get(challenge) {
60
+ prune()
61
+ const entry = requests.get(challenge)
62
+ return entry ? snapshot(entry) : null
63
+ },
64
+ /**
65
+ * 处理入站 verification_claim / verification_receipt 动作。
66
+ * @param {string} action 网络动作
67
+ * @param {object} payload 网络载荷
68
+ * @param {string} sender 已认证身份
69
+ */
70
+ async receive(action, payload, sender) {
71
+ if (!isHex64(sender) || !isHex64(payload?.challenge)) return
72
+ if (action === 'verification_claim') {
73
+ const entry = requests.get(payload.challenge)
74
+ if (!entry || now() >= entry.expiresAt || snapshot(entry).status === 'failed' || entry.expiresAt !== payload.expiresAt) return
75
+ if (entry.status === 'verified' && entry.nodeHash !== sender) return
76
+ entry.status = 'verified'
77
+ entry.nodeHash = sender
78
+ await send(sender, 'verification_receipt', { challenge: entry.challenge, expiresAt: entry.expiresAt })
79
+ }
80
+ if (action === 'verification_receipt') {
81
+ const proof = proofs.get(payload.challenge)
82
+ if (proof && proof.requesterNodeHash === sender && proof.expiresAt === payload.expiresAt && now() < proof.expiresAt)
83
+ proof.resolve({ status: 'verified', nodeHash })
84
+ }
85
+ },
86
+ /**
87
+ * 向请求方发起验证并等待回执(去重、并发上限、超时收敛为 failed)。
88
+ * @param {NetworkChallenge} request 挑战请求
89
+ * @returns {Promise<object>} 验证结果
90
+ */
91
+ async prove({ requesterNodeHash, challenge, expiresAt }) {
92
+ if (!isHex64(requesterNodeHash) || !isHex64(challenge) || !Number.isSafeInteger(expiresAt) || expiresAt <= now() || expiresAt > now() + MAX_TIMEOUT)
93
+ return { status: 'failed', reason: 'invalid challenge' }
94
+ if (proofs.has(challenge)) {
95
+ const existing = proofs.get(challenge)
96
+ if (existing.requesterNodeHash !== requesterNodeHash || existing.expiresAt !== expiresAt) return { status: 'failed', reason: 'challenge mismatch' }
97
+ return existing.promise
98
+ }
99
+ if (proofs.size >= 64) return { status: 'failed', reason: 'busy' }
100
+ let resolve
101
+ const promise = new Promise(done => { resolve = done })
102
+ proofs.set(challenge, { requesterNodeHash, expiresAt, resolve, promise })
103
+ const timer = setTimeout(() => resolve({ status: 'failed', reason: 'timeout' }), Math.min(expiresAt - now(), 15000))
104
+ try {
105
+ void send(requesterNodeHash, 'verification_claim', { challenge, expiresAt }).then(sent => {
106
+ if (!sent) resolve({ status: 'failed', reason: 'unreachable' })
107
+ }, () => resolve({ status: 'failed', reason: 'unreachable' }))
108
+ return await promise
109
+ }
110
+ finally { clearTimeout(timer); proofs.delete(challenge) }
111
+ },
112
+ }
113
+ }
114
+
115
+ let service
116
+ /**
117
+ * 把验证动作挂到正在运行的节点的 node scope 上。
118
+ * @returns {() => void} 卸载 dispose
119
+ */
120
+ export function attachNetworkVerification() {
121
+ return attachNodeScopeFeature('verification', wire => {
122
+ const current = getNetworkVerificationService()
123
+ const disposers = ['verification_claim', 'verification_receipt'].map(action => wire.on(action, (payload, sender) => {
124
+ void current.receive(action, payload, sender).catch(() => {})
125
+ }))
126
+ return () => { for (const dispose of disposers) dispose(); service = null }
127
+ })
128
+ }
129
+
130
+ /**
131
+ * 取当前节点的验证服务(懒创建单例)。
132
+ * @returns {ReturnType<typeof createNetworkVerificationService>} 验证服务
133
+ */
134
+ export function getNetworkVerificationService() {
135
+ if (!service) service = createNetworkVerificationService({ nodeHash: getNodeHash(), send: sendVerificationEnvelope })
136
+ return service
137
+ }
138
+
139
+ /**
140
+ * 经已认证链路投递验证消息;请求方就是本节点时直接走本地入口。
141
+ * @param {string} peer 目标身份
142
+ * @param {string} action 网络动作
143
+ * @param {object} payload 网络载荷
144
+ * @returns {Promise<boolean>} 是否已投递
145
+ */
146
+ async function sendVerificationEnvelope(peer, action, payload) {
147
+ if (peer === getNodeHash()) {
148
+ await getNetworkVerificationService().receive(action, payload, peer)
149
+ return true
150
+ }
151
+ return await sendToNodeLink(peer, { scope: 'node', action, payload })
152
+ }
153
+
154
+ /**
155
+ * 单次自证:证明本节点可达请求方并收到其回执。
156
+ * @param {NetworkChallenge} request 挑战请求
157
+ * @returns {Promise<object>} 验证结果
158
+ */
159
+ export async function proveNetworkVerification(request) {
160
+ const dispose = attachNetworkVerification()
161
+ try { return await getNetworkVerificationService().prove(request) }
162
+ finally { dispose() }
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.44",
3
+ "version": "0.0.48",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -25,7 +25,9 @@
25
25
  "test:fount": "deno test -A --config deno.json test/fount/",
26
26
  "test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
27
27
  "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs",
28
- "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs"
28
+ "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs",
29
+ "test:checks": "node --test --test-concurrency=1 --test-force-exit test/pure/text_lf.test.mjs test/pure/jsdoc_no_english.test.mjs test/pure/agents_md_english.test.mjs",
30
+ "check:text_lf:fix": "node scripts/checks/text_lf.mjs --fix .."
29
31
  },
30
32
  "bin": {
31
33
  "fount-p2p": "infra/cli.mjs"
package/sim/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ In-process co-evolution of **tunables** (`*.tunables.json`) against an **attack
15
15
  - **`PARAM_SPACE` ↔ defaults:** every `PARAM_SPACE` key must exist in `loadDefaultTunables()`; clear both sides when deleting a key.
16
16
  - **`socialRepHideThreshold`:** hide when `score < threshold`. Default `0` (suppress negatives only). Raise the threshold to raise `falsePositiveRate` — never use a negative threshold for "stricter".
17
17
  - **Heuristic proxy** (not the real path): `model.mjs`, `discovery.mjs`, `transport.mjs`, `integrity.mjs` — analytical "params → defense" only.
18
- - **Mesh-first:** explore slots and cold-start join are mandatory; fanout/trust stay separate from link presence. Policy: [docs/mesh.md](../docs/mesh.md). Wiring: [cold_start.md](cold_start.md).
18
+ - **Mesh-first:** explore slots and cold-start join are mandatory; fanout/trust stay separate from link presence. Policy: [docs/mesh.md](../docs/mesh.md). Wiring: [docs/cold_start.md](docs/cold_start.md).
19
19
 
20
20
  ## Anti-drift
21
21
 
@@ -1,6 +1,6 @@
1
1
  # Sim cold-start (K = 0)
2
2
 
3
- Mesh policy: [docs/mesh.md](../docs/mesh.md). Day-to-day sim rules: [AGENTS.md](AGENTS.md).
3
+ Mesh policy: [mesh.md](../../docs/mesh.md). Day-to-day sim rules: [AGENTS.md](../AGENTS.md).
4
4
 
5
5
  When the observer has no acquaintances (`trustedPeers=[]`), the sim must still join via discovery explore slots — not idle.
6
6
 
@@ -1,13 +1,14 @@
1
- import { mkdir } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
3
-
4
1
  import { topologicalCanonicalOrder } from '../dag/index.mjs'
5
- import { readJsonl, writeJsonlSynced } from '../dag/storage.mjs'
2
+ import { jsonlMutexKey, readJsonlEntries, writeJsonlLines } from '../dag/storage.mjs'
6
3
  import { invalidateTopologicalOrderMemo } from '../federation/topo_order_memo.mjs'
7
4
  import { descendantClosureFromTip } from '../governance/branch.mjs'
5
+ import { withAsyncMutex } from '../utils/async_mutex.mjs'
8
6
 
9
7
  /**
10
8
  * 将 events.jsonl 裁剪为 checkpoint 起后代闭包(连通子图,非拓扑下标切片)。
9
+ *
10
+ * 整个读-算-写在 `jsonlMutexKey(eventsFilePath)` 锁内完成,避免与并发 append 竞态;
11
+ * 写回的是保留行的原始字节(非 sanitize 后的重序列化),保持无损失。
11
12
  * @param {string} eventsFilePath events.jsonl 路径
12
13
  * @param {object | null} checkpoint 含 checkpoint_event_id 的快照
13
14
  * @param {(row: object) => object} [sanitize] 行规范化
@@ -16,24 +17,27 @@ import { descendantClosureFromTip } from '../governance/branch.mjs'
16
17
  export async function pruneEventsJsonlAfterCheckpoint(eventsFilePath, checkpoint, sanitize = row => row) {
17
18
  const tipId = checkpoint?.checkpoint_event_id
18
19
  if (!tipId) return { pruned: false, kept: 0, dropped: 0 }
19
- const events = await readJsonl(eventsFilePath, { sanitize })
20
- if (!events.length) return { pruned: false, kept: 0, dropped: 0 }
21
- const byId = new Map(events.map(dagEvent => [dagEvent.id, dagEvent]))
22
- if (!byId.has(tipId)) return { pruned: false, kept: events.length, dropped: 0 }
20
+ return withAsyncMutex(jsonlMutexKey(eventsFilePath), async () => {
21
+ const entries = await readJsonlEntries(eventsFilePath, { sanitize })
22
+ if (!entries.length) return { pruned: false, kept: 0, dropped: 0 }
23
+ const byId = new Map(entries.map(entry => [entry.row.id, entry.row]))
24
+ const rawById = new Map(entries.map(entry => [entry.row.id, entry.raw]))
25
+ if (!byId.has(tipId)) return { pruned: false, kept: entries.length, dropped: 0 }
23
26
 
24
- const keepIds = descendantClosureFromTip(tipId, byId)
25
- const order = topologicalCanonicalOrder(events.map(dagEvent => ({
26
- id: dagEvent.id,
27
- prev_event_ids: dagEvent.prev_event_ids,
28
- hlc: dagEvent.hlc,
29
- node_id: dagEvent.node_id,
30
- sender: dagEvent.sender,
31
- })))
32
- const kept = order.map(id => byId.get(id)).filter(ev => ev && keepIds.has(ev.id))
33
- const dropped = events.length - kept.length
34
- if (dropped <= 0) return { pruned: false, kept: kept.length, dropped: 0 }
35
- await mkdir(dirname(eventsFilePath), { recursive: true })
36
- await writeJsonlSynced(eventsFilePath, kept)
37
- invalidateTopologicalOrderMemo(eventsFilePath)
38
- return { pruned: true, kept: kept.length, dropped }
27
+ const keepIds = descendantClosureFromTip(tipId, byId)
28
+ const order = topologicalCanonicalOrder(entries.map(entry => ({
29
+ id: entry.row.id,
30
+ prev_event_ids: entry.row.prev_event_ids,
31
+ hlc: entry.row.hlc,
32
+ node_id: entry.row.node_id,
33
+ sender: entry.row.sender,
34
+ })))
35
+ const keptIds = order.filter(id => keepIds.has(id))
36
+ const dropped = entries.length - keptIds.length
37
+ if (dropped <= 0) return { pruned: false, kept: keptIds.length, dropped: 0 }
38
+ const keptRaws = keptIds.map(id => rawById.get(id)).filter(raw => raw !== undefined)
39
+ await writeJsonlLines(eventsFilePath, keptRaws)
40
+ invalidateTopologicalOrderMemo(eventsFilePath)
41
+ return { pruned: true, kept: keptIds.length, dropped }
42
+ })
39
43
  }
@@ -1,13 +1,13 @@
1
- import { mkdir } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
3
-
4
1
  import { topologicalCanonicalOrder } from '../dag/index.mjs'
5
- import { readJsonl, writeJsonlSynced } from '../dag/storage.mjs'
2
+ import { jsonlMutexKey, readJsonlEntries, writeJsonlLines } from '../dag/storage.mjs'
6
3
  import { invalidateTopologicalOrderMemo } from '../federation/topo_order_memo.mjs'
7
4
  import { computeDagTipIdsFromEvents, selectConsensusBranchTip } from '../governance/branch.mjs'
8
5
  import { computeRetentionKeepIds } from '../node/retention_policy.mjs'
6
+ import { withAsyncMutex } from '../utils/async_mutex.mjs'
9
7
 
10
8
  /**
9
+ * 整个读-算-写在 `jsonlMutexKey(eventsFilePath)` 锁内完成,避免与并发 append 竞态;
10
+ * 写回的是保留行的原始字节(非 sanitize 后的重序列化),保持无损失。
11
11
  * @param {string} eventsFilePath events.jsonl 路径
12
12
  * @param {object | null} checkpointHint 检查点提示
13
13
  * @param {{ maxDepth: number, maxMs: number, anchorTypes: Set<string> }} policy 保留策略
@@ -20,35 +20,39 @@ export async function enforceTimelineEventRetention(
20
20
  policy,
21
21
  sanitize = row => row,
22
22
  ) {
23
- const events = await readJsonl(eventsFilePath, { sanitize })
24
- if (!events.length) return { pruned: false, kept: 0, dropped: 0 }
25
- const maxDepth = Math.max(256, Number(policy.maxDepth) || 200_000)
26
- const maxMs = Math.max(3_600_000, Number(policy.maxMs) || 365 * 24 * 3600 * 1000)
27
- const cutoffWall = Date.now() - maxMs
28
- const byId = new Map(events.map(e => [e.id, e]))
29
- const order = topologicalCanonicalOrder(events.map(e => ({
30
- id: e.id,
31
- prev_event_ids: e.prev_event_ids,
32
- hlc: e.hlc,
33
- node_id: e.node_id,
34
- sender: e.sender,
35
- })))
36
- const tips = computeDagTipIdsFromEvents(events)
37
- const branchTipId = selectConsensusBranchTip(tips, byId)
38
- const checkpointTipId = checkpointHint?.checkpoint_event_id || null
39
- const keepIds = computeRetentionKeepIds(order, byId, {
40
- maxDepth,
41
- cutoffWall,
42
- anchorTypes: policy.anchorTypes,
43
- checkpointTipId,
44
- branchTipId,
23
+ return withAsyncMutex(jsonlMutexKey(eventsFilePath), async () => {
24
+ const entries = await readJsonlEntries(eventsFilePath, { sanitize })
25
+ if (!entries.length) return { pruned: false, kept: 0, dropped: 0 }
26
+ const maxDepth = Math.max(256, Number(policy.maxDepth) || 200_000)
27
+ const maxMs = Math.max(3_600_000, Number(policy.maxMs) || 365 * 24 * 3600 * 1000)
28
+ const cutoffWall = Date.now() - maxMs
29
+ const events = entries.map(entry => entry.row)
30
+ const rawById = new Map(entries.map(entry => [entry.row.id, entry.raw]))
31
+ const byId = new Map(events.map(e => [e.id, e]))
32
+ const order = topologicalCanonicalOrder(events.map(e => ({
33
+ id: e.id,
34
+ prev_event_ids: e.prev_event_ids,
35
+ hlc: e.hlc,
36
+ node_id: e.node_id,
37
+ sender: e.sender,
38
+ })))
39
+ const tips = computeDagTipIdsFromEvents(events)
40
+ const branchTipId = selectConsensusBranchTip(tips, byId)
41
+ const checkpointTipId = checkpointHint?.checkpoint_event_id || null
42
+ const keepIds = computeRetentionKeepIds(order, byId, {
43
+ maxDepth,
44
+ cutoffWall,
45
+ anchorTypes: policy.anchorTypes,
46
+ checkpointTipId,
47
+ branchTipId,
48
+ })
49
+ if (keepIds.size >= events.length) return { pruned: false, kept: events.length, dropped: 0 }
50
+ const keptIds = order.filter(id => keepIds.has(id))
51
+ const dropped = events.length - keptIds.length
52
+ if (dropped <= 0) return { pruned: false, kept: keptIds.length, dropped: 0 }
53
+ const keptRaws = keptIds.map(id => rawById.get(id)).filter(raw => raw !== undefined)
54
+ await writeJsonlLines(eventsFilePath, keptRaws)
55
+ invalidateTopologicalOrderMemo(eventsFilePath)
56
+ return { pruned: true, kept: keptIds.length, dropped }
45
57
  })
46
- if (keepIds.size >= events.length) return { pruned: false, kept: events.length, dropped: 0 }
47
- const kept = order.map(id => byId.get(id)).filter(ev => ev && keepIds.has(ev.id))
48
- const dropped = events.length - kept.length
49
- if (dropped <= 0) return { pruned: false, kept: kept.length, dropped: 0 }
50
- await mkdir(dirname(eventsFilePath), { recursive: true })
51
- await writeJsonlSynced(eventsFilePath, kept)
52
- invalidateTopologicalOrderMemo(eventsFilePath)
53
- return { pruned: true, kept: kept.length, dropped }
54
58
  }
@@ -25,6 +25,24 @@ import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
25
25
  const DIAL_COOLDOWN_BASE_MS = ms('30s')
26
26
  /** dial 冷却上限。 */
27
27
  const DIAL_COOLDOWN_MAX_MS = ms('10m')
28
+ /**
29
+ * 单次确保直连的整体上限:覆盖全部 provider 尝试(含 offer/answer 握手)后必须结算。
30
+ * 必须大于 nostr 入队 publish 等待上限,让「relay 连不上」走 provider 自身的失败路径,
31
+ * 而不是在这里被掐断(否则拿不到 provider 级诊断)。见 fount-p2p#37。
32
+ */
33
+ export const LINK_DIAL_DEADLINE_MS = ms('90s')
34
+
35
+ /** 测试可缩短的单次直连上限。 */
36
+ let dialDeadlineMs = LINK_DIAL_DEADLINE_MS
37
+
38
+ /**
39
+ * 覆盖单次直连上限(测试用;传 null 恢复默认)。
40
+ * @param {number | null} value 毫秒上限;null 恢复默认
41
+ * @returns {void}
42
+ */
43
+ export function setLinkDialDeadlineMsForTests(value) {
44
+ dialDeadlineMs = value == null ? LINK_DIAL_DEADLINE_MS : value
45
+ }
28
46
 
29
47
  /**
30
48
  * 解析或从节点种子推导本地身份。
@@ -263,8 +281,58 @@ export function createLinkRegistry(options = {}) {
263
281
  return links.get(remoteNodeHash) ?? null
264
282
  }
265
283
 
284
+ /**
285
+ * 记一次 dial 失败并进入指数退避。
286
+ * @param {string} normalized 远端节点 64 hex
287
+ * @returns {number} 本次冷却毫秒
288
+ */
289
+ function noteDialFailure(normalized) {
290
+ const failures = (dialCooldown.get(normalized)?.failures || 0) + 1
291
+ const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
292
+ dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
293
+ return delay
294
+ }
295
+
296
+ /**
297
+ * 为一个已登记的直连尝试套上整体上限:超时即返回 null(调用方不再等),
298
+ * 后台尝试照旧收敛,成功仍会注册链路。
299
+ *
300
+ * 超时后立刻从 inflights 摘除,后续 dial 走新尝试而不是复用这个卡住的 promise。
301
+ * @param {string} normalized 远端节点 64 hex
302
+ * @param {Promise<object | null>} task 进行中的 dial
303
+ * @returns {Promise<object | null>} dial 结果;超时 null
304
+ */
305
+ async function awaitDialAttempt(normalized, task) {
306
+ if (dialDeadlineMs <= 0) return await task
307
+ /** @type {boolean} */
308
+ let expired = false
309
+ /** @type {ReturnType<typeof setTimeout> | null} */
310
+ let timer = null
311
+ const timeout = new Promise(resolve => {
312
+ timer = setTimeout(() => {
313
+ expired = true
314
+ resolve(null)
315
+ }, dialDeadlineMs)
316
+ timer.unref?.()
317
+ })
318
+ const link = await Promise.race([task, timeout])
319
+ if (timer) clearTimeout(timer)
320
+ if (!expired) return link
321
+ // 超时:调用方不再等,但后台尝试仍会自行收敛(成功照样注册链路,失败自己记冷却)。
322
+ if (inflights.get(normalized) === task) inflights.delete(normalized)
323
+ const cooldownMs = noteDialFailure(normalized)
324
+ nodeDebug('p2p:dial deadline', {
325
+ peer: shortHash(normalized),
326
+ deadlineMs: dialDeadlineMs,
327
+ cooldownMs,
328
+ })
329
+ return null
330
+ }
331
+
266
332
  /**
267
333
  * 按 level 降序尝试各 LinkProvider,不可用/不可达/失败则回落。
334
+ * 整体有 LINK_DIAL_DEADLINE_MS 上限:卡住的 provider 既不能让调用方永久等待,
335
+ * 也不能把坏 promise 留在 inflights 里毒化后续 dial。
268
336
  * @param {string} remoteNodeHash 远端节点 64 hex
269
337
  * @returns {Promise<object | null>} 链路实例;失败时 null
270
338
  */
@@ -274,7 +342,7 @@ export function createLinkRegistry(options = {}) {
274
342
  const normalized = remoteNodeHash
275
343
  if (!normalized || normalized === localIdentity.nodeHash) return null
276
344
  if (links.has(normalized)) return links.get(normalized)
277
- if (inflights.has(normalized)) return await inflights.get(normalized)
345
+ if (inflights.has(normalized)) return await awaitDialAttempt(normalized, inflights.get(normalized))
278
346
  const cool = dialCooldown.get(normalized)
279
347
  const now = Date.now()
280
348
  if (cool) {
@@ -335,14 +403,12 @@ export function createLinkRegistry(options = {}) {
335
403
  })
336
404
  }
337
405
 
338
- const failures = (cool?.failures || 0) + 1
339
- const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
340
- dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
341
- nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs: delay, failures })
406
+ const cooldownMs = noteDialFailure(normalized)
407
+ nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs })
342
408
  return null
343
409
  })().finally(() => inflights.delete(normalized))
344
410
  inflights.set(normalized, task)
345
- return await task
411
+ return await awaitDialAttempt(normalized, task)
346
412
  }
347
413
 
348
414
  meshKeepalive = createMeshKeepalive({
@@ -387,9 +453,12 @@ export function createLinkRegistry(options = {}) {
387
453
 
388
454
  /**
389
455
  * 启动 discovery/link runtime 并开启 mesh keepalive。
456
+ * 顺带重启 peer health 订阅:`shutdown()` 会停掉它,重启运行时后必须重新挂上,
457
+ * 否则 getPeerHealth / listPeerHealth 会永远为空(见 fount-p2p#38)。
390
458
  */
391
459
  const ensureRuntimeWithMesh = async () => {
392
460
  await bootstrap.ensureRuntime()
461
+ peerHealth.start()
393
462
  meshKeepalive?.start()
394
463
  }
395
464
 
@@ -19,6 +19,7 @@ import { emitSafe } from '../utils/emit_safe.mjs'
19
19
  * getPeerHealth: (nodeHash: string) => PeerHealthEntry | null
20
20
  * listPeerHealth: () => PeerHealthEntry[]
21
21
  * onPeerHealth: (listener: (nodeHash: string, entry: PeerHealthEntry) => void) => () => void
22
+ * start: () => void
22
23
  * stop: () => void
23
24
  * }} peer health 聚合器
24
25
  */
@@ -29,6 +30,10 @@ export function createPeerHealthTracker(registry) {
29
30
  const cleanups = new Map()
30
31
  /** @type {Set<(nodeHash: string, entry: PeerHealthEntry) => void>} */
31
32
  const listeners = new Set()
33
+ /** @type {(() => void) | null} registry link up 退订 */
34
+ let stopUp = null
35
+ /** @type {(() => void) | null} registry link down 退订 */
36
+ let stopDown = null
32
37
 
33
38
  /**
34
39
  * 合并补丁并通知订阅方。
@@ -54,37 +59,48 @@ export function createPeerHealthTracker(registry) {
54
59
  emitSafe(listeners, nodeHash, entry)
55
60
  }
56
61
 
57
- const stopUp = registry.onLinkUp?.((nodeHash, link) => {
58
- if (!nodeHash) return
59
- const stopRtt = link?.onRtt?.(() => {
60
- const stats = link.stats?.() ?? {}
62
+ /**
63
+ * 订阅 registry 的链路事件(幂等)。
64
+ * `stop()` 之后可用它重新挂上监听:registry 的 link up/down 桶在 shutdown 时不会被清空,
65
+ * 所以重启运行时后健康记录能自然恢复。
66
+ * @returns {void}
67
+ */
68
+ function start() {
69
+ if (stopUp) return
70
+ stopUp = registry.onLinkUp?.((nodeHash, link) => {
71
+ if (!nodeHash) return
72
+ const stopRtt = link?.onRtt?.(() => {
73
+ const stats = link.stats?.() ?? {}
74
+ update(nodeHash, {
75
+ rttMs: stats.rttMs ?? null,
76
+ avgRttMs: stats.avgRttMs ?? null,
77
+ lastSeenAt: Date.now(),
78
+ })
79
+ }) ?? null
80
+ const stopLinkDown = link?.onDown?.(() => {
81
+ cleanups.get(nodeHash)?.()
82
+ cleanups.delete(nodeHash)
83
+ update(nodeHash, { connected: false, lastSeenAt: Date.now() })
84
+ }) ?? null
61
85
  update(nodeHash, {
62
- rttMs: stats.rttMs ?? null,
63
- avgRttMs: stats.avgRttMs ?? null,
86
+ connected: true,
87
+ source: link?.providerId ?? null,
64
88
  lastSeenAt: Date.now(),
65
89
  })
90
+ cleanups.set(nodeHash, () => {
91
+ stopRtt?.()
92
+ stopLinkDown?.()
93
+ })
66
94
  }) ?? null
67
- const stopDown = link?.onDown?.(() => {
95
+ stopDown = registry.onLinkDown?.(nodeHash => {
96
+ if (!nodeHash) return
68
97
  cleanups.get(nodeHash)?.()
69
98
  cleanups.delete(nodeHash)
70
99
  update(nodeHash, { connected: false, lastSeenAt: Date.now() })
71
100
  }) ?? null
72
- update(nodeHash, {
73
- connected: true,
74
- source: link?.providerId ?? null,
75
- lastSeenAt: Date.now(),
76
- })
77
- cleanups.set(nodeHash, () => {
78
- stopRtt?.()
79
- stopDown?.()
80
- })
81
- }) ?? null
82
- const stopDown = registry.onLinkDown?.(nodeHash => {
83
- if (!nodeHash) return
84
- cleanups.get(nodeHash)?.()
85
- cleanups.delete(nodeHash)
86
- update(nodeHash, { connected: false, lastSeenAt: Date.now() })
87
- }) ?? null
101
+ }
102
+
103
+ start()
88
104
 
89
105
  return {
90
106
  /**
@@ -108,17 +124,19 @@ export function createPeerHealthTracker(registry) {
108
124
  listeners.add(listener)
109
125
  return () => listeners.delete(listener)
110
126
  },
127
+ start,
111
128
  /**
112
- * 停止监听并清空聚合。
129
+ * 停止监听并清空聚合(listen 订阅保留,listeners 订阅者保留)。
113
130
  * @returns {void}
114
131
  */
115
132
  stop() {
116
133
  stopUp?.()
117
134
  stopDown?.()
135
+ stopUp = null
136
+ stopDown = null
118
137
  for (const cleanup of cleanups.values()) cleanup()
119
138
  cleanups.clear()
120
139
  entries.clear()
121
- listeners.clear()
122
140
  },
123
141
  }
124
142
  }