@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.
- package/AGENTS.md +17 -4
- package/dag/storage.mjs +157 -79
- package/discovery/nostr/census.mjs +5 -5
- package/discovery/nostr/census_monitor.mjs +2 -2
- package/discovery/nostr/constants.mjs +1 -0
- package/discovery/nostr/index.mjs +24 -12
- package/discovery/nostr/session.mjs +96 -12
- package/docs/nostr_relay_discovery.md +2 -0
- package/docs/signaling.md +15 -3
- package/docs/transports.md +15 -1
- package/federation/part_query/runtime.mjs +4 -4
- package/index.mjs +5 -0
- package/link/providers/ble_gatt.mjs +2 -2
- package/link/providers/index.mjs +2 -1
- package/link/providers/nostr/index.mjs +19 -6
- package/link/providers/webrtc.mjs +235 -56
- package/link/rtc/polyfill.mjs +9 -6
- package/mailbox/store.mjs +3 -22
- package/node/signaling_config.mjs +30 -8
- package/node/verification.mjs +163 -0
- package/package.json +4 -2
- package/sim/AGENTS.md +1 -1
- package/sim/{cold_start.md → docs/cold_start.md} +1 -1
- package/timeline/prune.mjs +27 -23
- package/timeline/retention.mjs +38 -34
- package/transport/link_registry.mjs +75 -6
- package/transport/peer_health.mjs +43 -25
- package/transport/runtime_bootstrap.mjs +44 -9
- package/utils/async_mutex.mjs +11 -4
- package/utils/shuffle.mjs +1 -1
- package/wire/adapter.mjs +1 -1
- package/pages/index.html +0 -36
- package/pages/index.mjs +0 -62
|
@@ -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.
|
|
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: [
|
|
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
|
|
package/timeline/prune.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|
package/timeline/retention.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|