@steve02081504/fount-p2p 0.0.48 → 0.0.50

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/AGENTS.md CHANGED
@@ -54,6 +54,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
54
54
  - Assertions: `test/helpers/assert.mjs`
55
55
  - Fixed-seed identity: `test/helpers/identity.mjs`
56
56
  - Mock discovery: `test/helpers/mock_discovery.mjs`
57
+ - Transport fakes must mirror the backend's failure signalling: a fake `RTCPeerConnection` whose `close()` stays silent (no `connectionstatechange`) hides re-entrancy bugs where a replaced connection closes the live one (fount-p2p#39, `link/providers/webrtc.mjs`).
57
58
 
58
59
  ## Static checks (`scripts/checks/`, ported from fount's check standard)
59
60
 
@@ -103,7 +103,7 @@ let discoveryEnabled = true
103
103
 
104
104
  /**
105
105
  * 规范化 Nostr relay URL(唯一入站入口)。
106
- * - 仅允许 wss://;`ws://` 仅限回环/私有地址(本地测试/开发)。
106
+ * - `wss://` 不限地址;`ws://` 只接受本机回环(`localhost` / `::1` / `127.x`),私网与公网一律拒绝。
107
107
  * - hostname 小写,去除默认端口,去除尾部斜杠,保留非空 path。
108
108
  * @param {unknown} raw 原始 URL
109
109
  * @returns {string | null} 规范化字符串,无效返回 null
@@ -250,7 +250,8 @@ function currentTrustedRelayUrls() {
250
250
 
251
251
  /**
252
252
  * 判定该 relay 是否允许作为连接目的地(probe/发布/订阅)。
253
- * 本机显式配置或 NIP-66 引导集保留信任例外;其余须为公网 hostname 且 DNS 全部解析为公网地址。
253
+ * 受信 relay(本机显式配置、提供方注册、NIP-66 引导集、pinned,见 [currentTrustedRelayUrls])不受公网限制;
254
+ * 其余须为公网 hostname 且 DNS 全部解析为公网地址。
254
255
  * @param {string} relayUrl 规范化 relay URL
255
256
  * @returns {Promise<boolean>} 允许连接为 true
256
257
  */
@@ -261,20 +262,32 @@ export async function isRelayDestinationAllowed(relayUrl) {
261
262
  return relayUrlResolvesPublic(relayUrl)
262
263
  }
263
264
 
265
+ /**
266
+ * 最近一次尝试是否失败。时间戳与 lastSuccess 打平按失败算(同一毫秒内的并发尝试里,成功者会记下更晚的时间戳)。
267
+ * @param {number} lastFailure 最近一次失败时间戳
268
+ * @param {number} lastSuccess 最近一次成功时间戳
269
+ * @returns {boolean} 最近一次尝试失败为 true
270
+ */
271
+ function latestAttemptFailed(lastFailure, lastSuccess) {
272
+ return lastFailure >= lastSuccess
273
+ }
274
+
264
275
  /**
265
276
  * 计算 relay 健康分(越低越优)。
277
+ * 最近一次尝试失败(或从未成功过)时 RTT 与失败率都取最差值,分数必定高于任何活着的 relay。
266
278
  * @param {Partial<RelayPoolEntry>} entry 条目
267
279
  * @returns {number} 健康分
268
280
  */
269
281
  export function computeRelayHealth(entry) {
270
- const { rttMs, successCount, failureCount, lastProbe } = entry
271
- const total = (successCount || 0) + (failureCount || 0)
272
- const failureRate = total > 0 ? (failureCount || 0) / total : 0
273
- let rtt = rttMs !== undefined && rttMs !== null ? Number(rttMs) : DEFAULT_RTT_MS
274
- if (!Number.isFinite(rtt)) rtt = DEFAULT_RTT_MS
275
- rtt = Math.max(1, Math.min(MAX_RTT_MS, rtt))
282
+ const { rttMs, successCount = 0, failureCount = 0, lastProbe = 0, lastSuccess = 0, lastFailure = 0 } = entry
283
+ const failed = failureCount > 0 && (!successCount || latestAttemptFailed(lastFailure, lastSuccess))
284
+ const total = successCount + failureCount
285
+ const failureRate = failed ? 1 : total > 0 ? failureCount / total : 0
286
+ // 失败的 relay 最后一次测量已被作废:没有可信 RTT 可依,按上限算。
287
+ const measuredRtt = failed ? MAX_RTT_MS : rttMs
288
+ const rtt = Math.max(1, Math.min(MAX_RTT_MS, Number.isFinite(measuredRtt) && measuredRtt > 0 ? measuredRtt : DEFAULT_RTT_MS))
276
289
  let score = rtt * (1 + failureRate * FAILURE_WEIGHT)
277
- if (Date.now() - (lastProbe || 0) > PROBE_STALE_MS) score *= STALE_PENALTY
290
+ if (Date.now() - lastProbe > PROBE_STALE_MS) score *= STALE_PENALTY
278
291
  return score
279
292
  }
280
293
 
@@ -512,7 +525,7 @@ export function recordProbeSuccess(url, rttMs) {
512
525
  const entry = ensureEntry(normalized)
513
526
  if (rttMs != null && Number.isFinite(Number(rttMs))) entry.rttMs = Math.max(0, Math.min(MAX_RTT_MS, Math.round(Number(rttMs))))
514
527
  entry.successCount++
515
- entry.lastSuccess = entry.lastProbe = entry.lastSeen = Date.now()
528
+ entry.lastSuccess = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
516
529
  markDirty()
517
530
  }
518
531
 
@@ -525,7 +538,8 @@ export function recordProbeFailure(url) {
525
538
  if (!normalized) return
526
539
  const entry = ensureEntry(normalized)
527
540
  entry.failureCount++
528
- entry.lastFailure = entry.lastProbe = entry.lastSeen = Date.now()
541
+ entry.rttMs = null
542
+ entry.lastFailure = entry.lastProbe = entry.lastSeen = Math.max(Date.now(), entry.lastProbe + 1)
529
543
  markDirty()
530
544
  }
531
545
 
@@ -583,17 +597,23 @@ export function clearStale() {
583
597
  }
584
598
 
585
599
  /**
586
- * @returns {RelayPoolEntry[]} 工作集(健康分升序,含全部 pinned)
600
+ * @returns {RelayPoolEntry[]} 工作集(保留未探测引导项,排除失败或过期项,优先 pinned)
587
601
  */
588
602
  export function getWorkingRelays() {
589
- const sorted = sortByHealth([...poolEntries.values()])
603
+ const now = Date.now()
604
+ // 合格项:最近一次尝试成功且没过期,或压根还没探测过的引导项。
605
+ const eligible = [...poolEntries.values()].filter(entry =>
606
+ entry.successCount === 0
607
+ ? entry.lastFailure === 0
608
+ : !latestAttemptFailed(entry.lastFailure, entry.lastSuccess) && now - entry.lastSuccess <= PROBE_STALE_MS)
609
+ const sorted = sortByHealth(eligible)
590
610
  const pinned = sorted.filter(isPinned)
591
611
  const rest = sorted.filter(entry => !isPinned(entry))
592
612
  return dedupeByUrl([...pinned, ...rest.slice(0, Math.max(0, WORKING_RELAYS_COUNT - pinned.length))])
593
613
  }
594
614
 
595
615
  /**
596
- * @returns {RelayPoolEntry[]} 监听/发布子集(含全部 public/manual)
616
+ * @returns {RelayPoolEntry[]} 监听/发布子集(仅含工作集内 public/manual)
597
617
  */
598
618
  export function getListenRelays() {
599
619
  const working = getWorkingRelays()
@@ -12,7 +12,6 @@ import {
12
12
  import {
13
13
  computeRelayHealth,
14
14
  getPeerRoute,
15
- getPinnedRelays,
16
15
  getPoolByUrl,
17
16
  getWorkingRelays,
18
17
  recordPublishResult,
@@ -116,7 +115,7 @@ export function expandFromHistory(nodeHash, base, limit) {
116
115
 
117
116
  /**
118
117
  * 计算到指定节点的当前轮路由目标集。
119
- * Round 0:对端声称前4(无则本机工作集前4,再空则 pinned)。
118
+ * Round 0:对端声称前4(无则本机工作集前4)。
120
119
  * Round ≥1:历史成功 + 对端综合分补足 + 加权采样,附退避;总扇出封顶。
121
120
  * @param {string} nodeHash 目标节点
122
121
  * @param {number} attempt 当前尝试轮次(0 起)
@@ -139,7 +138,6 @@ export function handshakeTargets(nodeHash, attempt) {
139
138
  for (const entry of working.slice(0, ROUND0_TARGET_COUNT)) push(entry.url)
140
139
  return
141
140
  }
142
- for (const url of getPinnedRelays().slice(0, ROUND0_TARGET_COUNT)) push(url)
143
141
  }
144
142
  if (attempt <= 0) {
145
143
  round0()
@@ -16,8 +16,10 @@ Source layout (`discovery/nostr/`):
16
16
 
17
17
  `relayPool` (all known) → `workingRelays` (top-`WORKING_RELAYS_COUNT` by health) → `listenRelays` (top-`LISTEN_RELAYS_COUNT` working subset used for publish/listen).
18
18
 
19
- - `public`/`manual` entries are **pinned**: always in `working`/`listen` and never evicted by `clearStale` or pool-cap. Pin count may exceed the nominal caps.
19
+ - `public`/`manual` entries are **pinned**: preferred in `working`/`listen` when eligible and never evicted by `clearStale` or pool-cap. Failed or stale pins remain in the pool for retry, but leave the working/listen sets. Pin count may exceed the nominal caps.
20
20
  - `nip66`/`peer` entries are disposable: evicted when stale (`PROBE_STALE_MS`) or when the pool exceeds `POOL_CAP`.
21
+ - A relay leaves the working/listen sets when it is **not eligible**: never probed but already failed, or its latest attempt failed, or its last success is older than `PROBE_STALE_MS`. Untested bootstrap entries stay eligible; exclusion never evicts from the pool, so dead relays keep getting retried.
22
+ - Configured `relayUrls` override the automatic listen subset; adverts publish this resolved subscription set as `listenNostrRelays`.
21
23
  - Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
22
24
  - Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
23
25
 
@@ -25,20 +27,22 @@ Source layout (`discovery/nostr/`):
25
27
 
26
28
  `normalizeNostrRelayUrl` (in `relays.mjs`) is the only entry for every inbound URL (NIP-66 `d` tag, manual config, peer advert):
27
29
 
28
- - `wss://` always; `ws://` only for loopback/private hosts (local dev/tests).
30
+ - `wss://` to any host; `ws://` to loopback only (`localhost` / `::1` / `127.x` — local dev/tests). Private and public `ws://` are rejected outright.
31
+ - `isRelayDestinationAllowed` then gates dialling: a **trusted** relay (local config, provider registration, the NIP-66 bootstrap set, or any pinned `public`/`manual` entry) may be private; every other relay must be a public hostname whose DNS resolves entirely to public addresses. So a private `wss://` is only reachable when something pinned or configured it.
29
32
  - hostname lowercased, default port removed, trailing slashes removed, non-empty path kept.
30
33
  - Invalid → `null` → dropped with an audit log (`nodeDebug('invalidRelayUrl', { url, reason })`), never silently cleaned.
31
34
 
32
35
  ## Health score
33
36
 
34
37
  ```text
35
- failureRate = failureCount / (successCount + failureCount)
36
- rtt = clamp(rttMs ?? DEFAULT_RTT_MS, 1, MAX_RTT_MS)
37
- score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
38
- score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
38
+ dead = latest attempt failed, or the entry never succeeded // ties count as failed
39
+ failureRate = dead ? 1 : failureCount / (successCount + failureCount)
40
+ rtt = clamp(dead ? MAX_RTT_MS : rttMs, 1, MAX_RTT_MS) // DEFAULT_RTT_MS when unknown, zero or invalid
41
+ score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
42
+ score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
39
43
  ```
40
44
 
41
- Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
45
+ Lower is better. A dead entry (`failureCount > 0` and either no successes or a latest failure) scores with `MAX_RTT_MS`, `failureRate = 1`, and its stale RTT discarded, so it can never outrank a live one. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters; attempt timestamps advance monotonically so success/failure ordering survives same-millisecond attempts. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
42
46
 
43
47
  ## Persistence
44
48
 
@@ -74,7 +78,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
74
78
 
75
79
  `handshakeTargets(nodeHash, attempt)`:
76
80
 
77
- - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
81
+ - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4. Empty working sets do not fall back to failed pins.
78
82
  - **Round ≥1**: backoff `min(2000 · 2^(attempt−1), 60000)`; base on `lastGoodNostrRelays` (expanded via `expandFromHistory` ≤ 16), or weighted-random sample of `workingRelays` (weight `1/score`); round-0 core always included; fanout capped at `MAX_ROUTING_FANOUT` (64).
79
83
  - Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
80
84
 
@@ -135,13 +135,14 @@ export async function createWebRtcLink(options) {
135
135
  const rungs = iceLocalHostnameLadder(icePolicy)
136
136
  const remoteSignalQueue = []
137
137
  const seenRemoteSignals = createLruMap(1024)
138
- /** @type {InstanceType<import('../rtc/polyfill.mjs').LoadedRtcPolyfill['RTCPeerConnection']> & { onicecandidate?: unknown, ondatachannel?: unknown, onconnectionstatechange?: unknown }} */
138
+ /** @type {InstanceType<import('../rtc/polyfill.mjs').LoadedRtcPolyfill['RTCPeerConnection']> & { ondatachannel?: unknown, onconnectionstatechange?: unknown, candidateCountHandler?: unknown }} */
139
139
  let peerConnection = null
140
140
  let remoteDescriptionSet = false
141
141
  let controlChannel = null
142
142
  let bulkChannel = null
143
143
  let unlistenRemote = null
144
144
  let sendQueues = null
145
+ let linkReady = false
145
146
  let controlLowEvents = 0
146
147
  let bulkLowEvents = 0
147
148
  let reconnectCount = 0
@@ -216,6 +217,8 @@ export async function createWebRtcLink(options) {
216
217
  }
217
218
  },
218
219
  })
220
+ // 数据链路是否已完成握手认证:完成后,发不出去的重协商答复不该再拆掉这条活链路。
221
+ void pipe.ready.then(() => { linkReady = true }, () => { })
219
222
 
220
223
  /**
221
224
  * @param {RTCDataChannel} channel RTC 数据通道
@@ -269,8 +272,12 @@ export async function createWebRtcLink(options) {
269
272
  remoteSignalQueue.length = 0
270
273
  localCandidateCount = 0
271
274
  attachPeerConnection(peerConnection)
272
- if (previous)
275
+ if (previous) {
276
+ // 先摘回调再关:旧连接 close() 会经 connectionstatechange 触发 pipe.close,而
277
+ // closeTransport() 关的是 peerConnection 这个变量(此刻已指向新连接),于是刚建好的一级被自己关掉(fount-p2p#39)。
278
+ detachPeerConnection(previous)
273
279
  try { await previous.close() } catch { /* ignore */ }
280
+ }
274
281
  nodeDebug('p2p:webrtc ice rung', {
275
282
  rung: rungIndex,
276
283
  policy: rungs[rungIndex],
@@ -288,22 +295,29 @@ export async function createWebRtcLink(options) {
288
295
 
289
296
  /**
290
297
  * 给 peer connection 挂上事件与回调(换级重建后需重新挂)。
298
+ *
299
+ * 回调一律用传入的这个连接,不读 `peerConnection` 闭包变量:换级后旧连接仍可能派发事件,
300
+ * 读变量就会把事件算到新连接头上。
291
301
  * @param {RTCPeerConnection} connection peer connection
292
302
  * @returns {void}
293
303
  */
294
304
  function attachPeerConnection(connection) {
295
305
  // 候选随 description 一次性带出(对端要先有 remoteDescription 才吃候选),故这里只计数、不外发。
296
- // 事件 API 由后端提供(W3C 是 addEventListener);缺失时退化为 onicecandidate 计数。
306
+ // 事件 API 由后端提供(W3C 是 addEventListener);缺失时退化为 onicecandidate,摘的时候看 candidateCountHandler 在不在。
297
307
  const countCandidate = event => {
298
308
  if (event?.candidate) localCandidateCount++
299
309
  }
300
- if (connection.addEventListener) connection.addEventListener('icecandidate', countCandidate)
310
+ if (connection.addEventListener) {
311
+ connection.candidateCountHandler = countCandidate
312
+ connection.addEventListener('icecandidate', countCandidate)
313
+ }
301
314
  else {
302
315
  const previousHandler = connection.onicecandidate
303
- connection.onicecandidate = event => {
316
+ connection.candidateCountHandler = event => {
304
317
  countCandidate(event)
305
318
  previousHandler?.(event)
306
319
  }
320
+ connection.onicecandidate = connection.candidateCountHandler
307
321
  }
308
322
  connection.ondatachannel = event => {
309
323
  attachChannel(event.channel)
@@ -317,6 +331,19 @@ export async function createWebRtcLink(options) {
317
331
  }
318
332
  }
319
333
 
334
+ /**
335
+ * 摘掉某一级 peer connection 上挂的事件与回调(重建前必须调用,见 [buildPeerConnection])。
336
+ * @param {RTCPeerConnection} connection 待摘的连接
337
+ * @returns {void}
338
+ */
339
+ function detachPeerConnection(connection) {
340
+ if (connection.candidateCountHandler) connection.removeEventListener?.('icecandidate', connection.candidateCountHandler)
341
+ else connection.onicecandidate = null
342
+ delete connection.candidateCountHandler
343
+ connection.ondatachannel = null
344
+ connection.onconnectionstatechange = null
345
+ }
346
+
320
347
  /**
321
348
  * 等本地候选收齐(停滞判定见 [collectIceGathering]),并回报本次是否产出了候选。
322
349
  * @returns {Promise<boolean>} 是否收到过候选
@@ -396,11 +423,18 @@ export async function createWebRtcLink(options) {
396
423
  candidates: localCandidateCount,
397
424
  hasCandidates,
398
425
  })
399
- await sendSignal({
400
- type: 'description',
401
- rung: rungIndex,
402
- description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
403
- })
426
+ try {
427
+ await sendSignal({
428
+ type: 'description',
429
+ rung: rungIndex,
430
+ description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
431
+ })
432
+ }
433
+ catch (error) {
434
+ // 已认证的数据链路不应被后续信令发送失败关闭。
435
+ if (!linkReady) throw error
436
+ nodeDebug('p2p:webrtc answer signal send failed after link ready', { reason: formatErrorReason(error) })
437
+ }
404
438
  await pipe.maybeSendAuth()
405
439
  }
406
440
  return
@@ -469,7 +503,7 @@ export async function createWebRtcLink(options) {
469
503
  void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
470
504
  }) ?? null
471
505
 
472
- // 信令随时可能到,故先为当前一级建好 pc(发起方随后会在阶梯里逐级重建)。
506
+ // 信令随时可能到,故先为当前一级(第 0 级)建好 pc;发起方阶梯的首次迭代直接用它,不重建。
473
507
  await ensurePeerConnection()
474
508
 
475
509
  if (options.initiator) {
@@ -478,7 +512,8 @@ export async function createWebRtcLink(options) {
478
512
  const ladderDeadline = Date.now() + handshakeTimeoutMs
479
513
  let sentOffer = false
480
514
  for (let index = 0; index < rungs.length; index++) {
481
- await buildPeerConnection(index)
515
+ // 同一级不重建:换 pc 会牵动并发信令与旧连接的关闭,只在真的换级时才需要(fount-p2p#39)。
516
+ if (rungIndex !== index) await buildPeerConnection(index)
482
517
  attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
483
518
  attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
484
519
  const offer = await peerConnection.createOffer()
@@ -491,7 +526,8 @@ export async function createWebRtcLink(options) {
491
526
  })
492
527
  // 候选集为空说明这一级策略把本机候选全滤掉了(例如只产出 mDNS 候选),换更宽松的一级重建。
493
528
  // 只在预算内、且还有更宽松的一级时升级;`rewrite-loopback` 起点不会走到这里(阶梯只有它自己)。
494
- if (!hasCandidates && Date.now() < ladderDeadline && index + 1 < rungs.length) {
529
+ const escalatable = index + 1 < rungs.length
530
+ if (!hasCandidates && escalatable && Date.now() < ladderDeadline) {
495
531
  nodeDebug('p2p:webrtc ice rung escalated', {
496
532
  from: rungs[index],
497
533
  to: rungs[index + 1],
@@ -509,7 +545,7 @@ export async function createWebRtcLink(options) {
509
545
  nodeDebug('p2p:webrtc offer has no usable ice candidates', {
510
546
  rung: index,
511
547
  policy: rungs[index],
512
- escalatable: index + 1 < rungs.length,
548
+ escalatable,
513
549
  iceServers: options.iceServers?.length || 0,
514
550
  })
515
551
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -15,7 +15,7 @@ import {
15
15
  createNostrDiscoveryProvider,
16
16
  resolveNostrRelayUrls,
17
17
  } from '../discovery/nostr/index.mjs'
18
- import { getListenRelays, getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
18
+ import { getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
19
19
  import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
20
20
  import {
21
21
  listLinkProviders,
@@ -188,7 +188,7 @@ export function createRuntimeBootstrap(deps) {
188
188
  .filter(entry => entry.rttMs != null)
189
189
  .slice(0, MAX_ADVERT_RELAY_POOL)
190
190
  .map(entry => ({ url: entry.url, rttMs: entry.rttMs })),
191
- listen: getListenRelays().map(entry => entry.url),
191
+ listen: resolveNostrRelayUrls(),
192
192
  }
193
193
  : { pool: [], listen: [] },
194
194
  )