@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.
@@ -16,6 +16,24 @@ const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
16
16
  const NOSTR_RECONNECT_DELAY_MS = 500
17
17
  /** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
18
18
  const NOSTR_IDLE_DROP_MS = 2_000
19
+ /**
20
+ * 已入队 publish 的等待上限(从入队起算,覆盖多次「连不上 → 退避重连」)。
21
+ * 连接超时 2s + 重连间隔 0.5s ≈ 2.5s/轮,20s 约等于 8 轮;连不上的 relay 必须结算,
22
+ * 否则请求永久留在 pendingPublishes 里(见 fount-p2p#37)。
23
+ */
24
+ export const NOSTR_QUEUED_PUBLISH_DEADLINE_MS = 20_000
25
+
26
+ /** 测试可缩短的入队 publish 等待上限。 */
27
+ let queuedPublishDeadlineMs = NOSTR_QUEUED_PUBLISH_DEADLINE_MS
28
+
29
+ /**
30
+ * 覆盖入队 publish 等待上限(测试用;传 null 恢复默认)。
31
+ * @param {number | null} value 毫秒上限;null 恢复默认
32
+ * @returns {void}
33
+ */
34
+ export function setQueuedPublishDeadlineMsForTests(value) {
35
+ queuedPublishDeadlineMs = value == null ? NOSTR_QUEUED_PUBLISH_DEADLINE_MS : value
36
+ }
19
37
 
20
38
  /**
21
39
  * @param {string[] | undefined | null} urls 原始列表
@@ -216,6 +234,7 @@ function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
216
234
  * attempt: number,
217
235
  * onAbort: (() => void) | null,
218
236
  * removeAbort: (() => void) | null,
237
+ * deadline: ReturnType<typeof setTimeout> | null,
219
238
  * resolve: (ok: boolean) => void,
220
239
  * reject: (err: Error) => void,
221
240
  * }} NostrPublishRequest
@@ -326,7 +345,7 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
326
345
  if (session.inflightPublishes.length) {
327
346
  session.pendingPublishes.push(...session.inflightPublishes)
328
347
  for (const publishRequest of session.inflightPublishes)
329
- attachQueuedAbort(session, publishRequest)
348
+ attachQueuedAbort(relayUrl, session, publishRequest)
330
349
  session.inflightPublishes = []
331
350
  }
332
351
  if (!hasPendingWork(session)) {
@@ -465,6 +484,8 @@ function flushPendingPublishes(relayUrl, session, socket) {
465
484
  publishRequest.removeAbort?.()
466
485
  publishRequest.onAbort = null
467
486
  publishRequest.removeAbort = null
487
+ // 请求已进入实际发送:等待上限改由本次 send 的 OK 超时负责。
488
+ clearQueuedDeadline(publishRequest)
468
489
  const attempt = ++publishRequest.attempt
469
490
  session.inflightPublishes.push(publishRequest)
470
491
  void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
@@ -475,23 +496,83 @@ function flushPendingPublishes(relayUrl, session, socket) {
475
496
  }
476
497
  }
477
498
 
499
+ /**
500
+ * 清除待发请求的等待上限定时器。
501
+ * @param {NostrPublishRequest} publishRequest 待发请求
502
+ * @returns {void}
503
+ */
504
+ function clearQueuedDeadline(publishRequest) {
505
+ if (!publishRequest.deadline) return
506
+ clearTimeout(publishRequest.deadline)
507
+ publishRequest.deadline = null
508
+ }
509
+
510
+ /**
511
+ * 把一个尚未发出的 publish 移出队列并清掉它的等待上限。
512
+ * 队列已在别处清空(已 flush 到 socket / 已 abort)时返回 false。
513
+ * @param {string} relayUrl 中继 URL
514
+ * @param {SharedRelaySession} session 会话
515
+ * @param {NostrPublishRequest} publishRequest 待发请求
516
+ * @returns {boolean} 是否确实已出队
517
+ */
518
+ function dequeueQueuedPublish(relayUrl, session, publishRequest) {
519
+ const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
520
+ if (pendingIndex < 0) return false
521
+ clearQueuedDeadline(publishRequest)
522
+ publishRequest.removeAbort?.()
523
+ publishRequest.onAbort = null
524
+ publishRequest.removeAbort = null
525
+ session.pendingPublishes.splice(pendingIndex, 1)
526
+ // 这条 relay 已无任何工作:立刻回收,别把连不上的 socket / 重连循环留在进程里。
527
+ if (!hasPendingWork(session)) {
528
+ sharedRelaySessions.delete(relayUrl)
529
+ clearSharedRelayReconnect(session)
530
+ if (session.ws) dropWebSocket(session.ws)
531
+ session.ws = null
532
+ }
533
+ return true
534
+ }
535
+
536
+ /**
537
+ * 为队列中的 publish 挂上等待上限:从入队起算,超时即 reject 并出队。
538
+ * 只覆盖「尚未拿到可用 socket」的阶段;已 flush 到 open socket 的请求由 publishEventOnRelay 的 OK 超时负责。
539
+ * 定时器跨断线重发保留(请求在 pending ↔ inflight 间移动时不重置),故僵尸请求的总等待有界。
540
+ * @param {string} relayUrl 中继 URL
541
+ * @param {SharedRelaySession} session 会话
542
+ * @param {NostrPublishRequest} publishRequest 待发请求
543
+ * @returns {void}
544
+ */
545
+ function attachQueuedDeadline(relayUrl, session, publishRequest) {
546
+ clearQueuedDeadline(publishRequest)
547
+ if (queuedPublishDeadlineMs <= 0) return
548
+ const timer = setTimeout(() => {
549
+ publishRequest.deadline = null
550
+ if (!dequeueQueuedPublish(relayUrl, session, publishRequest)) return
551
+ nodeDebug('p2p:nostr publish deadline', {
552
+ url: relayUrl,
553
+ err: `nostr: connect timeout for ${relayUrl}`,
554
+ })
555
+ publishRequest.reject(new Error(`nostr: connect timeout for ${relayUrl}`))
556
+ }, queuedPublishDeadlineMs)
557
+ timer.unref?.()
558
+ publishRequest.deadline = timer
559
+ }
560
+
478
561
  /**
479
562
  * 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
480
563
  * 再从队列删除并 reject。
564
+ * @param {string} relayUrl 中继 URL
481
565
  * @param {SharedRelaySession} session 会话
482
566
  * @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
483
567
  * @returns {void}
484
568
  */
485
- function attachQueuedAbort(session, publishRequest) {
569
+ function attachQueuedAbort(relayUrl, session, publishRequest) {
486
570
  const { signal, reject } = publishRequest
487
571
  /**
488
572
  * 处理 abort 事件
489
573
  */
490
574
  publishRequest.onAbort = () => {
491
- publishRequest.removeAbort?.()
492
- const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
493
- if (pendingIndex < 0) return
494
- session.pendingPublishes.splice(pendingIndex, 1)
575
+ if (!dequeueQueuedPublish(relayUrl, session, publishRequest)) return
495
576
  reject(new Error('nostr: aborted'))
496
577
  }
497
578
  /**
@@ -525,7 +606,8 @@ function settleInflightPublish(relayUrl, session, publishRequest, attempt, settl
525
606
 
526
607
  /**
527
608
  * 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
528
- * 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
609
+ * 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发;
610
+ * 连不上的 relay 由入队等待上限(NOSTR_QUEUED_PUBLISH_DEADLINE_MS)reject,不会永久悬挂。
529
611
  * @param {string} relayUrl 中继 URL
530
612
  * @param {object} event 待发布事件
531
613
  * @param {AbortSignal} [signal] 取消信号
@@ -541,13 +623,15 @@ export function publishViaSharedRelay(relayUrl, event, signal, connectTarget) {
541
623
  const session = acquireSharedRelay(relayUrl, connectTarget)
542
624
  clearIdleDrop(session)
543
625
  /** @type {SharedRelaySession['pendingPublishes'][number]} */
544
- const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
545
- attachQueuedAbort(session, publishRequest)
626
+ const publishRequest = { event, signal, attempt: 0, deadline: null, resolve, reject, onAbort: null, removeAbort: null }
627
+ attachQueuedAbort(relayUrl, session, publishRequest)
546
628
  session.pendingPublishes.push(publishRequest)
547
- if (session.ws?.readyState === WebSocket.OPEN)
629
+ if (session.ws?.readyState === WebSocket.OPEN) {
548
630
  flushPendingPublishes(relayUrl, session, session.ws)
549
- else
550
- scheduleSharedRelayConnect(relayUrl, session)
631
+ return
632
+ }
633
+ attachQueuedDeadline(relayUrl, session, publishRequest)
634
+ scheduleSharedRelayConnect(relayUrl, session)
551
635
  })
552
636
  }
553
637
 
@@ -3,6 +3,7 @@
3
3
  Nostr is the fount-network fallback transport (`link` level −∞, discovery `priority` 100). This document covers the **relay pool**, NIP-66 discovery, health scoring, advert relay-field signing, and handshake routing. Top-level surface: [signaling.md](signaling.md) · [transports.md](transports.md) · [mesh.md](mesh.md).
4
4
 
5
5
  Source layout (`discovery/nostr/`):
6
+
6
7
  - `index.mjs` — provider (presence / signal / advert subscriptions)
7
8
  - `relays.mjs` — pool, health, NIP-66 discovery, normalization, persistence
8
9
  - `selection.mjs` — handshake routing, backoff, fanout
@@ -72,6 +73,7 @@ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishRes
72
73
  ## Handshake routing (`selection.mjs`)
73
74
 
74
75
  `handshakeTargets(nodeHash, attempt)`:
76
+
75
77
  - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
76
78
  - **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).
77
79
  - Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
package/docs/signaling.md CHANGED
@@ -14,9 +14,20 @@ Internal WebRTC (`needsOfferAnswer`) glare and handshake. Shells use the fount-n
14
14
 
15
15
  Frames: `hello` then `auth`. On simultaneous dial, peer `auth` can arrive before peer `hello` — buffer it (`pendingAuth` in `link/pipe.mjs`); never drop.
16
16
 
17
- ## Windows / `trickleIceOff`
17
+ ## ICE candidate gathering and the local-hostname ladder
18
18
 
19
- When set: send final offer/answer after ICE gathering, dedupe remote signals, queue remote ICE until both descriptions are ready.
19
+ There is no `trickleIceOff` switch: candidates travel inside the description, because a peer must already hold the remote description before it accepts candidates. Every link therefore gathers locally first and then sends one offer/answer.
20
+
21
+ `.local` (mDNS) host candidates are usually unresolvable for the peer, so the default policy drops them. When that leaves the local candidate set **empty**, the link cannot work no matter what the peer supports, so the initiator escalates to the next, more permissive policy and rebuilds the peer connection on that rung (`ICE_LOCAL_HOSTNAME_LADDER` in `node/signaling_config.mjs`; `iceLocalHostnameLadder` derives the rungs from the configured start):
22
+
23
+ 1. `drop` — solve the mDNS problem by not offering the candidate.
24
+ 2. `none` — offer everything and let the peer decide.
25
+
26
+ This decision is driven purely by the observed candidate set, never by the platform, so any backend on any host follows the same path. `rewrite-loopback` is not part of the ladder (rewriting a candidate to `127.0.0.1` is only useful same-machine); it stays available as an explicit start and, when set, is used alone.
27
+
28
+ Each rung is a **fresh link attempt** — new peer connection, new offer, new DTLS fingerprint — not a renegotiation of a live connection, so `pipe` fingerprint-binding semantics are unchanged. The offer carries the rung number; the responder rebuilds on that same rung and echoes it in the answer and never escalates on its own, which keeps convergence bounded. The whole ladder shares the `handshakeTimeoutMs` budget.
29
+
30
+ Gathering itself is bounded by `collectIceGathering` (`link/providers/webrtc.mjs`): it finishes on `iceGatheringState === 'complete'`, on a quiet candidate stream after candidates arrived (`ICE_CANDIDATE_SETTLE_MS`), or after `ICE_GATHERING_STALL_MS` when nothing was gathered at all — the last case is logged, and DTLS/data-channel timeouts decide instead of a hard 10s failure. `handshakeTimeoutMs` stays the hard stop on every path, including the quiet-window wait. Server-side polyfills (`node-datachannel`) deliver candidates only through `icecandidate` events and `localDescription.sdp` carries no candidate lines, so progress is counted from events rather than by diffing SDP.
20
31
 
21
32
  ## Runtime channels
22
33
 
@@ -25,9 +36,10 @@ When set: send final offer/answer after ICE gathering, dedupe remote signals, qu
25
36
  `channels` selects which discovery/link media are active — `nostr`, `lan`, `bt`, `webrtc`. Semantics per channel key: `false` **disables**; any other value (`undefined`, `true`, or an object) **enables** — an object is merged over that channel's default config. Channels not mentioned keep their default enabled state.
26
37
 
27
38
  Channel → components:
39
+
28
40
  - `nostr` (nostr discovery + nostr link) — config `relay` **replaces** the default public relay list (do not merge defaults back in).
29
41
  - `lan` (lan discovery + lan_tcp link)
30
42
  - `bt` (bt discovery + ble_gatt link)
31
- - `webrtc` (webrtc link data-transport fallback) — config `iceLocalHostnamePolicy` (`none` / `rewrite-loopback` / `drop`, Windows defaults `drop`) and `trickleIceOff` (send final offer/answer after ICE gathering).
43
+ - `webrtc` (webrtc link data-transport fallback) — config `iceLocalHostnamePolicy` is the ladder's **start** (`drop` by default; `none`, or `rewrite-loopback` for same-machine debugging).
32
44
 
33
45
  To run a node on only a few channels, use the `disableAllChannels` helper: `{ channels: disableAllChannels({ nostr: { relay: [...] } }) }` enables only `nostr` (with its per-channel relay) and disables the rest; use `true` to enable a channel with its default config (`disableAllChannels({ nostr: true, webrtc: true })`).
@@ -45,10 +45,20 @@ LinkHandle for upper layers: `ready` / `nodeHash` / `send` / `onEnvelope` / `onD
45
45
 
46
46
  Provider optional hooks (package-internal): `ensureListening`, `localEndpoint`, `canReach`, `caps.probe: 'sync' | 'native'` (`native` = skipped on ensureRuntime fast-listen). Discovery `connectToNode` / `sendNodeSignal` may return `false` when the path is unavailable; fan-out treats that as silent skip. Per-provider throw/false in discovery and link dial fallback are silent; only total failure of the abstraction surfaces to the caller.
47
47
 
48
- Each registry only calls `ensureListening` on **its own** `lan_tcp` / `ble_gatt` instances (unique registry ids like `lan_tcp:ab12cd34`). Never fan out listening to other registries' sockets.
48
+ Each registry only owns its own `lan_tcp` / `ble_gatt` instances (unique registry ids like `lan_tcp:ab12cd34`), so those are listened to per registry. Every other **registered and enabled** provider (e.g. `nostr`, `webrtc`) is listened to by the registry that can see it: `ensureRuntime` and `reloadDiscoveryRelays` call `ensureListening` on any enabled provider that has no registered stop function yet. A provider registered later — notably the fresh instance `reconcileLinkProviders()` installs when a channel is toggled off and back on — must be picked up by that pass, otherwise it silently drops every inbound `link-open` (a missing listener is logged as `p2p:nostr link-open dropped — listener not attached`).
49
49
 
50
50
  Chain `providerId` on the LinkHandle stays the short name (`lan_tcp` / `ble_gatt` / `webrtc` / `nostr`) for scheduling/stats.
51
51
 
52
+ ## Bounded waits (no unbounded dial / publish)
53
+
54
+ Nothing on the dial path may wait forever:
55
+
56
+ - Intra-relay publish (`discovery/nostr/session.mjs`): a request queued behind a relay that never completes its WebSocket connect is rejected with a connect-stage error after `NOSTR_QUEUED_PUBLISH_DEADLINE_MS`, and the session is reclaimed. The EVent OK timeout only starts once a socket is open, so it cannot cover the connect stage.
57
+ - Relay fan-out (`discovery/nostr/index.mjs` `publishEvent`): resolves as soon as **one** relay accepts; the remaining publishes keep running in the background but are bounded by the above.
58
+ - One `ensureLinkToNode` (`transport/link_registry.mjs`): bounded by `LINK_DIAL_DEADLINE_MS` (larger than the publish deadline, so a dead relay normally fails through the provider's own error path). On timeout the in-flight promise is dropped from `inflights` so later dials start a fresh attempt instead of reusing a stuck one.
59
+
60
+ `kotlin/` mirrors all three; there a connect coroutine holding `sessionMutex` must also be cancellable (see [kotlin/AGENTS.md](../../kotlin/AGENTS.md)).
61
+
52
62
  ## Level table
53
63
 
54
64
  | id | level |
@@ -81,6 +91,10 @@ Plain TCP on the LAN. Registry schedules listen in the background after `ensureR
81
91
 
82
92
  Discovery signal + dual DataChannel; DTLS fingerprint as handshake binding; `needsOfferAnswer` glare path. Soft-fail (`null`) continues to lower-level providers. Backend: `node-datachannel` when the native addon loads, else pure-JS `node-rtc-connection` (Android/Termux skips native). See [runtime.md](runtime.md).
83
93
 
94
+ Every link gathers local ICE candidates first and then sends one offer/answer (there is no trickle mode: a peer must already hold the remote description before it accepts candidates). `collectIceGathering` (`link/providers/webrtc.mjs`) therefore does not wait for `iceGatheringState === 'complete'` alone — server-side polyfills leave the state at `gathering` when every relay times out or when the local-hostname policy drops all host candidates. It also finishes on a quiet candidate stream (`ICE_CANDIDATE_SETTLE_MS`) and gives up after `ICE_GATHERING_STALL_MS` when nothing was gathered, so DTLS/data-channel timeouts decide instead of a hard failure. `handshakeTimeoutMs` remains the hard stop on every path. Progress is counted from `icecandidate` events, not SDP diffs, because `localDescription.sdp` carries no candidate lines on those backends.
95
+
96
+ When the configured local-hostname policy drains every candidate the link cannot work at all, so the initiator escalates through `ICE_LOCAL_HOSTNAME_LADDER` (`drop` → `none`) and rebuilds the peer connection per rung; the responder follows the rung named in the offer. Nothing here is platform-specific. Details: [signaling.md](signaling.md).
97
+
84
98
  ### `ble_gatt` (40)
85
99
 
86
100
  GATT write/notify; binding = shared `linkId`; needs BT peer hint (`peripheralId` in discovery meta); optional noble/bleno. Per-registry instance like `lan_tcp`; `isAvailable` / `canReach` gate dial. On Win32, scan-only stacks cannot accept inbound BLE links. One BLE adapter cannot host two independent peripherals in-process — production is one node per process. Hardware probe: [runtime.md](runtime.md).
@@ -227,7 +227,7 @@ function mergeRowsWithSources(entries, maxHits, rowKey) {
227
227
  try { return JSON.stringify(row) }
228
228
  catch { return `\0${rows.length}` }
229
229
  })
230
- for (const entry of entries) {
230
+ for (const entry of entries)
231
231
  for (const row of entry.rows || []) {
232
232
  const key = keyOf(row)
233
233
  if (!seen.has(key)) {
@@ -240,7 +240,7 @@ function mergeRowsWithSources(entries, maxHits, rowKey) {
240
240
  }
241
241
  if (entry.sourceNodeHash) sources.get(key).add(entry.sourceNodeHash)
242
242
  }
243
- }
243
+
244
244
  return { rows, sources }
245
245
  }
246
246
 
@@ -325,10 +325,10 @@ function filterRowsBySource(rows, sources, rowKey, isSourceBlocked) {
325
325
  return rows.filter(row => {
326
326
  let key
327
327
  if (rowKey) key = rowKey(row)
328
- else {
328
+ else
329
329
  try { key = JSON.stringify(row) }
330
330
  catch { return true }
331
- }
331
+
332
332
  const set = sources.get(key)
333
333
  if (!set || set.size === 0) return true
334
334
  for (const source of set) if (!isSourceBlocked(source)) return true
package/index.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  unlockReputationMax,
38
38
  } from './node/reputation_sync.mjs'
39
39
  import { getRoutingProfile, setRoutingProfile } from './node/routing_profile.mjs'
40
+ import { attachNetworkVerification, createNetworkVerificationService, getNetworkVerificationService, proveNetworkVerification } from './node/verification.mjs'
40
41
  import { createGroupLinkSet } from './transport/group_link_set.mjs'
41
42
  import {
42
43
  configureLinkRegistry,
@@ -62,12 +63,14 @@ import {
62
63
  * 包门面:节点、infra、mesh/registry、信誉同步、node-scope 等公开导出。
63
64
  */
64
65
  export {
66
+ attachNetworkVerification,
65
67
  attachReputationSyncWire,
66
68
  attachNodeScopeDefaultFeatures,
67
69
  closeNode,
68
70
  configureLinkRegistry,
69
71
  configureNodeStorage,
70
72
  createGroupLinkSet,
73
+ createNetworkVerificationService,
71
74
  createScopedLinkRoom,
72
75
  ensureChannelAvailable,
73
76
  ensureLinkToNode,
@@ -76,6 +79,7 @@ export {
76
79
  ensureOverlayRouter,
77
80
  ensureUserRoom,
78
81
  getLinkRegistry,
82
+ getNetworkVerificationService,
79
83
  getNodeDir,
80
84
  getNodeHash,
81
85
  getNodePopulationEstimate,
@@ -93,6 +97,7 @@ export {
93
97
  loadReputation,
94
98
  lockReputationMax,
95
99
  onPeerHealth,
100
+ proveNetworkVerification,
96
101
  pullReputationFromNode,
97
102
  registerDiscoveryProvider,
98
103
  registerLinkProvider,
@@ -63,7 +63,7 @@ async function openGattPipe(options) {
63
63
  }
64
64
 
65
65
  /**
66
- * Central dial。
66
+ * central 角色主动拨号:按 peer hint 扫描外围设备并建立 GATT 链路。
67
67
  * @param {object} options dial 选项
68
68
  * @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
69
69
  */
@@ -187,7 +187,7 @@ export function createBleGattLinkProvider() {
187
187
  uuid: BLE_DATA_CHAR_UUID,
188
188
  properties: ['write', 'writeWithoutResponse', 'notify'],
189
189
  /**
190
- * stoprocent/bleno onWriteRequest(connection, data, offset, withoutResponse, callback)
190
+ * peripheral 角色的写入回调(stoprocent/bleno onWriteRequest 签名)。
191
191
  * @param {*} _connection 连接句柄
192
192
  * @param {Buffer} data 写入
193
193
  * @param {number} _offset 偏移
@@ -9,7 +9,8 @@
9
9
  * onDown: (callback: (reason: string) => void) => () => void,
10
10
  * close: (reason?: string) => Promise<void>,
11
11
  * stats: () => object,
12
- * }} LinkHandle */
12
+ * }} LinkHandle
13
+ */
13
14
 
14
15
  /**
15
16
  * @typedef {{
@@ -5,6 +5,7 @@ import { base64ToBytes, bytesToBase64 } from '../../../core/bytes_codec.mjs'
5
5
  import { isHex64 } from '../../../core/hexIds.mjs'
6
6
  import { getDiscoveryProvider, sendNodeSignalPacket } from '../../../discovery/index.mjs'
7
7
  import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../../discovery/nostr/index.mjs'
8
+ import { nodeDebug, shortHash } from '../../../node/log.mjs'
8
9
  import { ms } from '../../../utils/duration.mjs'
9
10
  import { createLruMap } from '../../../utils/lru.mjs'
10
11
  import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../../frame.mjs'
@@ -12,9 +13,11 @@ import { asLinkHandle } from '../../pipe.mjs'
12
13
  import { LINK_LEVEL_NOSTR } from '../levels.mjs'
13
14
  import { createLinkIdBoundPipe } from '../link_id_pipe.mjs'
14
15
 
15
- /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
16
- * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
17
- * 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。 */
16
+ /**
17
+ * 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
18
+ * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
19
+ * 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。
20
+ */
18
21
  export const MAX_LINK_PAYLOAD_CHARS = 131072
19
22
 
20
23
  /** relay info(NIP-11)单次探测超时。 */
@@ -52,8 +55,10 @@ export function estimateEventMessageBytes(packet) {
52
55
  }])).length
53
56
  }
54
57
 
55
- /** relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
56
- * 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。 */
58
+ /**
59
+ * relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
60
+ * 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。
61
+ */
57
62
  export const MIN_USABLE_RELAY_CAP_CHARS = estimateEventMessageBytes({
58
63
  type: 'link',
59
64
  op: 'b',
@@ -323,7 +328,15 @@ export function createNostrLinkProvider(options = {}) {
323
328
  const op = String(packet.op || '')
324
329
  if (op === 'open') {
325
330
  if (sessions.has(linkId)) return
326
- if (!onInbound || !localIdentity) return
331
+ if (!onInbound || !localIdentity) {
332
+ // 监听未挂上(例如通道关掉再打开后 registry 没重新 ensureListening)时入站包会被丢弃;
333
+ // 静默丢弃曾让「对端明明在线却始终握手超时」无法诊断(见 fount-p2p#38)。
334
+ nodeDebug('p2p:nostr link-open dropped — listener not attached', {
335
+ peer: shortHash(from),
336
+ linkId: shortHash(linkId),
337
+ })
338
+ return
339
+ }
327
340
  await refreshPayloadCap(resolveRelayUrls())
328
341
  if (sessions.has(linkId)) return
329
342
  const pipe = openPipe({