trac-msb 0.2.26 → 0.2.27
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/package.json +5 -2
- package/rpc/handlers.js +9 -0
- package/rpc/routes/v1.js +2 -0
- package/scripts/hypercore-stallfix/apply.mjs +199 -0
- package/scripts/hypercore-stallfix/hypercore-11.18.3.mjs +434 -0
- package/scripts/hypercore-stallfix/hypercore-11.8.3.mjs +348 -0
- package/src/config/config.js +5 -0
- package/src/config/env.js +3 -0
- package/src/core/network/services/ReplicationStallGuard.js +177 -0
- package/src/index.js +28 -0
- package/src/utils/hypercoreStallfix.js +53 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Exact-text patch set for hypercore 11.8.3 lib/replicator.js (OPENMAYHEM-STALLFIX v2).
|
|
2
|
+
// Same logic as hypercore-11.18.3.mjs, written against the 11.8.3 source (older code style, NOT_AVAILABLE has no
|
|
3
|
+
// reason field on the wire in 11.8.3, INVALID_REQUEST does not exist yet).
|
|
4
|
+
// Each edit is [original, replacement]; the original must occur exactly once in the unpatched file.
|
|
5
|
+
// Keep this file free of backticks and dollar-brace sequences inside the edit texts (String.raw templates).
|
|
6
|
+
|
|
7
|
+
// Upstream c24f37a6 (#911): a close event that already fired cannot detach a later attachment.
|
|
8
|
+
const closedStreamGuard = [
|
|
9
|
+
String.raw` attachTo (protomux) {
|
|
10
|
+
if (this.core.closed) return
|
|
11
|
+
`,
|
|
12
|
+
String.raw` attachTo (protomux) {
|
|
13
|
+
if (this.core.closed) return
|
|
14
|
+
if (protomux.stream.destroying) return
|
|
15
|
+
`
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
version: '11.8.3',
|
|
20
|
+
file: 'lib/replicator.js',
|
|
21
|
+
originalSha256: '1b4ddcd0d95d804b84a17a21b9076f990b133f78254b5255e5d32f0951cacf15',
|
|
22
|
+
patchedSha256: '0456d47322a34852e2c9244a0128971d8358fe7575d584e0f025eca3db5f654c',
|
|
23
|
+
// Only this exact released v1 file is eligible for an in-place upgrade.
|
|
24
|
+
previousPatchedSha256: '078afd835dc3e9cb0f7ee294a260528f633c05b54f8bf8276dd3d94aef15248b',
|
|
25
|
+
upgradeEdits: [
|
|
26
|
+
['// OPENMAYHEM-STALLFIX v1 (hypercore 11.8.3).', '// OPENMAYHEM-STALLFIX v2 (hypercore 11.8.3).'],
|
|
27
|
+
["const STALLFIX = 'openmayhem-stallfix-v1'", "const STALLFIX = 'openmayhem-stallfix-v2'"],
|
|
28
|
+
closedStreamGuard
|
|
29
|
+
],
|
|
30
|
+
edits: [
|
|
31
|
+
// 1. Constants + marker.
|
|
32
|
+
[
|
|
33
|
+
String.raw`const MAX_RANGES = 64
|
|
34
|
+
`,
|
|
35
|
+
String.raw`const MAX_RANGES = 64
|
|
36
|
+
|
|
37
|
+
// OPENMAYHEM-STALLFIX v2 (hypercore 11.8.3). Install-time patch shipped by trac-msb and trac-peer
|
|
38
|
+
// (scripts/hypercore-stallfix/). Fixes the waiting-side replication stall: an in-flight request that a peer
|
|
39
|
+
// never answers is cancelled after STALLFIX_REQUEST_MAX_AGE and asked elsewhere; that peer gets a cool-down;
|
|
40
|
+
// a peer we paused gets a capped NOT_AVAILABLE instead of silence. Wire format unchanged.
|
|
41
|
+
const STALLFIX = 'openmayhem-stallfix-v2'
|
|
42
|
+
const STALLFIX_REQUEST_MAX_AGE = 30000 // ms an in-flight request may wait for its answer
|
|
43
|
+
const STALLFIX_SWEEP_INTERVAL = 5000 // one unref'd timer per replicator, only while requests are in flight
|
|
44
|
+
const STALLFIX_MAX_SCAN = 4096 // request slots looked at per sweep
|
|
45
|
+
const STALLFIX_MAX_EXPIRE = 64 // requests expired per sweep
|
|
46
|
+
const STALLFIX_COOLDOWN_MIN = 30000 // first cool-down of a peer that let a request expire (doubles, capped)
|
|
47
|
+
const STALLFIX_COOLDOWN_MAX = 240000
|
|
48
|
+
const STALLFIX_NOT_AVAILABLE_WINDOW = 60000
|
|
49
|
+
const STALLFIX_NOT_AVAILABLE_MAX = 16 // NOT_AVAILABLE replies per window to a peer we paused; silent beyond
|
|
50
|
+
`
|
|
51
|
+
],
|
|
52
|
+
// 2. Per-peer state.
|
|
53
|
+
[
|
|
54
|
+
String.raw` this.lastExtensionRecv = ''
|
|
55
|
+
|
|
56
|
+
replicator._ifAvailable++
|
|
57
|
+
replicator._active++
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
String.raw` this.lastExtensionRecv = ''
|
|
61
|
+
|
|
62
|
+
this._sfTimeouts = 0 // OPENMAYHEM-STALLFIX: cool-down level of this peer
|
|
63
|
+
this._sfCoolUntil = 0
|
|
64
|
+
this._sfNaWindow = 0
|
|
65
|
+
this._sfNaCount = 0
|
|
66
|
+
|
|
67
|
+
replicator._ifAvailable++
|
|
68
|
+
replicator._active++
|
|
69
|
+
}
|
|
70
|
+
`
|
|
71
|
+
],
|
|
72
|
+
// 3. Responder: reply "no data" (capped) to a peer we paused instead of dropping the request silently.
|
|
73
|
+
// Also covers proof === null for an inactive peer (that case threw a TypeError here).
|
|
74
|
+
[
|
|
75
|
+
String.raw` if (!this.isActive() && proof.block !== null) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
String.raw` if (!this.isActive() && (proof === null || proof.block !== null)) {
|
|
80
|
+
// OPENMAYHEM-STALLFIX: upstream returns silently here and the remote waits forever.
|
|
81
|
+
this._sfNotAvailable(req.msg.id)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
`
|
|
85
|
+
],
|
|
86
|
+
// 4. Peer helpers: capped "no data" and request expiry.
|
|
87
|
+
[
|
|
88
|
+
String.raw` _cancelRequest (req) {
|
|
89
|
+
if (req.priority === PRIORITY.CANCELLED) return
|
|
90
|
+
`,
|
|
91
|
+
String.raw` // OPENMAYHEM-STALLFIX: at most STALLFIX_NOT_AVAILABLE_MAX replies per window, silent beyond the cap.
|
|
92
|
+
_sfNotAvailable (id) {
|
|
93
|
+
if (this.removed === true) return
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
if (now - this._sfNaWindow >= STALLFIX_NOT_AVAILABLE_WINDOW) {
|
|
96
|
+
this._sfNaWindow = now
|
|
97
|
+
this._sfNaCount = 0
|
|
98
|
+
}
|
|
99
|
+
if (this._sfNaCount >= STALLFIX_NOT_AVAILABLE_MAX) {
|
|
100
|
+
this.replicator._sfStats.notAvailableSuppressed++
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
this._sfNaCount++
|
|
104
|
+
this.replicator._sfStats.notAvailableSent++
|
|
105
|
+
this.wireNoData.send({ request: id })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// OPENMAYHEM-STALLFIX: give up on one request this peer did not answer in time. Bookkeeping as for a
|
|
109
|
+
// "no data" reply (_clearRequest re-queues a wanted block) plus a wire cancel. The id is only reused
|
|
110
|
+
// after the next roundtrip (as in _cancelRequest), so a late reply finds no request and is dropped
|
|
111
|
+
// in ondata/onnodata: it is not invalid data and does not pause the peer.
|
|
112
|
+
_sfExpireRequest (req, now) {
|
|
113
|
+
if (req.priority === PRIORITY.CANCELLED) return false
|
|
114
|
+
req.priority = PRIORITY.CANCELLED
|
|
115
|
+
|
|
116
|
+
this.inflight--
|
|
117
|
+
this.replicator._requestDone(req.id, false)
|
|
118
|
+
|
|
119
|
+
if (this.roundtripQueue === null) this.roundtripQueue = new RoundtripQueue()
|
|
120
|
+
this.roundtripQueue.add(req.id)
|
|
121
|
+
if (this.removed === false) {
|
|
122
|
+
this.wireCancel.send({ request: req.id })
|
|
123
|
+
incrementTx(this.stats.wireCancel, this.replicator.stats.wireCancel)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (this._sfCoolUntil <= now) {
|
|
127
|
+
if (this._sfTimeouts < 8) this._sfTimeouts++
|
|
128
|
+
const cooldown = this.replicator._sfCooldownMin * 2 ** (this._sfTimeouts - 1)
|
|
129
|
+
this._sfCoolUntil = now + Math.min(STALLFIX_COOLDOWN_MAX, cooldown)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.replicator._manifestPeer === this && this.core.header.manifest === null) {
|
|
133
|
+
this.replicator._manifestPeer = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.replicator._clearRequest(this, req)
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_cancelRequest (req) {
|
|
141
|
+
if (req.priority === PRIORITY.CANCELLED) return
|
|
142
|
+
`
|
|
143
|
+
],
|
|
144
|
+
// 5. Arm the sweep when a request goes in flight.
|
|
145
|
+
[
|
|
146
|
+
String.raw` this.inflight++
|
|
147
|
+
this.replicator._inflight.add(req)
|
|
148
|
+
`,
|
|
149
|
+
String.raw` this.inflight++
|
|
150
|
+
this.replicator._inflight.add(req)
|
|
151
|
+
if (this.replicator._sfTimer === null) this.replicator._sfArm() // OPENMAYHEM-STALLFIX
|
|
152
|
+
`
|
|
153
|
+
],
|
|
154
|
+
// 6. Marker on the Replicator class (runtime check: core.replicator.constructor.STALLFIX).
|
|
155
|
+
[
|
|
156
|
+
String.raw` static Peer = Peer // hack to be able to access Peer from outside this module
|
|
157
|
+
`,
|
|
158
|
+
String.raw` static Peer = Peer // hack to be able to access Peer from outside this module
|
|
159
|
+
static STALLFIX = STALLFIX // OPENMAYHEM-STALLFIX marker
|
|
160
|
+
`
|
|
161
|
+
],
|
|
162
|
+
// 7. Replicator state.
|
|
163
|
+
[
|
|
164
|
+
String.raw` this._notDownloadingLinger = notDownloadingLinger
|
|
165
|
+
this._downloadingTimer = null
|
|
166
|
+
`,
|
|
167
|
+
String.raw` this._notDownloadingLinger = notDownloadingLinger
|
|
168
|
+
this._downloadingTimer = null
|
|
169
|
+
|
|
170
|
+
// OPENMAYHEM-STALLFIX state (the _sf* numbers are only changed by tests)
|
|
171
|
+
this._sfTimer = null
|
|
172
|
+
this._sfCursor = 0
|
|
173
|
+
this._sfAllCooling = false
|
|
174
|
+
this._sfMaxAge = STALLFIX_REQUEST_MAX_AGE
|
|
175
|
+
this._sfInterval = STALLFIX_SWEEP_INTERVAL
|
|
176
|
+
this._sfCooldownMin = STALLFIX_COOLDOWN_MIN
|
|
177
|
+
this._sfStats = { expired: 0, notAvailableSent: 0, notAvailableSuppressed: 0 }
|
|
178
|
+
`
|
|
179
|
+
],
|
|
180
|
+
// 8. Valid block data from a peer ends its cool-down.
|
|
181
|
+
[
|
|
182
|
+
String.raw` _ondata (peer, req, data) {
|
|
183
|
+
req.elapsed = Date.now() - req.timestamp
|
|
184
|
+
if (data.block !== null) {
|
|
185
|
+
`,
|
|
186
|
+
String.raw` _ondata (peer, req, data) {
|
|
187
|
+
req.elapsed = Date.now() - req.timestamp
|
|
188
|
+
if (data.block !== null) {
|
|
189
|
+
peer._sfTimeouts = 0 // OPENMAYHEM-STALLFIX
|
|
190
|
+
peer._sfCoolUntil = 0
|
|
191
|
+
`
|
|
192
|
+
],
|
|
193
|
+
// 9. The sweep (bounded work, one timer per replicator).
|
|
194
|
+
[
|
|
195
|
+
String.raw` _maybeUpdate () {
|
|
196
|
+
return this._upgrade !== null && this._upgrade.inflight.length === 0
|
|
197
|
+
}
|
|
198
|
+
`,
|
|
199
|
+
String.raw` _maybeUpdate () {
|
|
200
|
+
return this._upgrade !== null && this._upgrade.inflight.length === 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// OPENMAYHEM-STALLFIX: one unref'd timer per replicator, armed by _send, re-armed only while requests are
|
|
204
|
+
// in flight or a peer is cooling down. Looks at <= STALLFIX_MAX_SCAN slots, expires <= STALLFIX_MAX_EXPIRE.
|
|
205
|
+
_sfArm () {
|
|
206
|
+
if (this.destroyed || this._sfTimer !== null) return
|
|
207
|
+
this._sfTimer = setTimeout(sfSweepLater, this._sfInterval, this)
|
|
208
|
+
if (this._sfTimer.unref) this._sfTimer.unref()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_sfSweep () {
|
|
212
|
+
this._sfTimer = null
|
|
213
|
+
if (this.destroyed) return
|
|
214
|
+
|
|
215
|
+
const now = Date.now()
|
|
216
|
+
const reqs = this._inflight._requests
|
|
217
|
+
const scan = Math.min(reqs.length, STALLFIX_MAX_SCAN)
|
|
218
|
+
let live = 0
|
|
219
|
+
let expired = 0
|
|
220
|
+
|
|
221
|
+
for (let i = 0; i < scan && expired < STALLFIX_MAX_EXPIRE; i++) {
|
|
222
|
+
if (this._sfCursor >= reqs.length) this._sfCursor = 0
|
|
223
|
+
const req = reqs[this._sfCursor++]
|
|
224
|
+
if (!req) continue
|
|
225
|
+
live++
|
|
226
|
+
if (now - req.timestamp < this._sfMaxAge) continue
|
|
227
|
+
if (req.peer._sfExpireRequest(req, now) === true) expired++
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let cooling = 0
|
|
231
|
+
let usable = 0
|
|
232
|
+
let cooled = false
|
|
233
|
+
for (const peer of this.peers) {
|
|
234
|
+
if (peer._sfCoolUntil !== 0 && peer._sfCoolUntil <= now) {
|
|
235
|
+
peer._sfCoolUntil = 0
|
|
236
|
+
cooled = true
|
|
237
|
+
}
|
|
238
|
+
if (peer._sfCoolUntil !== 0) cooling++
|
|
239
|
+
else if (peer.isActive()) usable++
|
|
240
|
+
}
|
|
241
|
+
// if no other peer is usable, a cooling peer may still be asked (no deadlock with a single peer)
|
|
242
|
+
this._sfAllCooling = cooling > 0 && usable === 0
|
|
243
|
+
|
|
244
|
+
if (expired > 0) this._sfStats.expired += expired
|
|
245
|
+
if (expired > 0 || cooled) this.updateAll()
|
|
246
|
+
|
|
247
|
+
if (live > 0 || cooling > 0 || scan < reqs.length) this._sfArm()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_sfCoolingDown (peer) {
|
|
251
|
+
return peer._sfCoolUntil !== 0 && this._sfAllCooling === false && peer._sfCoolUntil > Date.now()
|
|
252
|
+
}
|
|
253
|
+
`
|
|
254
|
+
],
|
|
255
|
+
// 10. Hotswap does not pick a cooling peer.
|
|
256
|
+
[
|
|
257
|
+
String.raw` if (!peer.isActive() || peer.inflight >= maxHotswaps) return
|
|
258
|
+
`,
|
|
259
|
+
String.raw` if (!peer.isActive() || peer.inflight >= maxHotswaps || this._sfCoolingDown(peer)) return
|
|
260
|
+
`
|
|
261
|
+
],
|
|
262
|
+
// 11. _updatePeer: skip a cooling peer + backport upstream 8cf8ad9 fix 2 (stale queue entry must not
|
|
263
|
+
// consume the peer's update).
|
|
264
|
+
[
|
|
265
|
+
String.raw` _updatePeer (peer) {
|
|
266
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight()) {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
`,
|
|
270
|
+
String.raw` _updatePeer (peer) {
|
|
271
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight() || this._sfCoolingDown(peer)) {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
`
|
|
275
|
+
],
|
|
276
|
+
[
|
|
277
|
+
String.raw` for (const b of blks) {
|
|
278
|
+
if (b.queued === false || peer._requestBlock(b) === true) {
|
|
279
|
+
b.queued = false
|
|
280
|
+
blks.dequeue()
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
`,
|
|
285
|
+
String.raw` for (const b of blks) {
|
|
286
|
+
if (b.queued === false) {
|
|
287
|
+
// upstream 8cf8ad9 (#896) fix 2, backported by OPENMAYHEM-STALLFIX
|
|
288
|
+
blks.dequeue()
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (peer._requestBlock(b) === true) {
|
|
292
|
+
b.queued = false
|
|
293
|
+
blks.dequeue()
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
`
|
|
298
|
+
],
|
|
299
|
+
// 12. _updatePeerNonPrimary: skip a cooling peer.
|
|
300
|
+
[
|
|
301
|
+
String.raw` _updatePeerNonPrimary (peer) {
|
|
302
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight()) {
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
`,
|
|
306
|
+
String.raw` _updatePeerNonPrimary (peer) {
|
|
307
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight() || this._sfCoolingDown(peer)) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
`
|
|
311
|
+
],
|
|
312
|
+
// 13. destroy() clears the sweep timer.
|
|
313
|
+
[
|
|
314
|
+
String.raw` if (this._downloadingTimer) {
|
|
315
|
+
clearTimeout(this._downloadingTimer)
|
|
316
|
+
this._downloadingTimer = null
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
while (this.peers.length) {
|
|
320
|
+
`,
|
|
321
|
+
String.raw` if (this._downloadingTimer) {
|
|
322
|
+
clearTimeout(this._downloadingTimer)
|
|
323
|
+
this._downloadingTimer = null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this._sfTimer !== null) {
|
|
327
|
+
clearTimeout(this._sfTimer) // OPENMAYHEM-STALLFIX
|
|
328
|
+
this._sfTimer = null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
while (this.peers.length) {
|
|
332
|
+
`
|
|
333
|
+
],
|
|
334
|
+
// 14. Timer trampoline.
|
|
335
|
+
[
|
|
336
|
+
String.raw`function setDownloadingLater (repl, downloading, session) {
|
|
337
|
+
`,
|
|
338
|
+
String.raw`function sfSweepLater (repl) {
|
|
339
|
+
repl._sfSweep() // OPENMAYHEM-STALLFIX
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function setDownloadingLater (repl, downloading, session) {
|
|
343
|
+
`
|
|
344
|
+
],
|
|
345
|
+
// Upstream #911, in addition to the existing stall fixes.
|
|
346
|
+
closedStreamGuard
|
|
347
|
+
]
|
|
348
|
+
}
|
package/src/config/config.js
CHANGED
|
@@ -224,6 +224,11 @@ export class Config {
|
|
|
224
224
|
return this.#config.confirmationStallTimeout
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
get replicationStallTimeout() {
|
|
228
|
+
if (this.#isOverriden('replicationStallTimeout')) return this.#options.replicationStallTimeout
|
|
229
|
+
return this.#config.replicationStallTimeout
|
|
230
|
+
}
|
|
231
|
+
|
|
227
232
|
get maxPendingRequestsInPendingRequestsService() {
|
|
228
233
|
return this.#config.maxPendingRequestsInPendingRequestsService
|
|
229
234
|
}
|
package/src/config/env.js
CHANGED
|
@@ -58,6 +58,7 @@ const configData = {
|
|
|
58
58
|
validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
|
|
59
59
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
60
60
|
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
61
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
61
62
|
storesDirectory: 'stores/',
|
|
62
63
|
storeName: 'testnet',
|
|
63
64
|
},
|
|
@@ -109,6 +110,7 @@ const configData = {
|
|
|
109
110
|
validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
|
|
110
111
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
111
112
|
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
113
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
112
114
|
storesDirectory: 'stores/',
|
|
113
115
|
storeName: 'mainnet',
|
|
114
116
|
},
|
|
@@ -160,6 +162,7 @@ const configData = {
|
|
|
160
162
|
validatorHealthCheckInterval: 1_000, // How often to check validator health (ms)
|
|
161
163
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
162
164
|
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
165
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
163
166
|
storesDirectory: 'stores/',
|
|
164
167
|
storeName: 'development',
|
|
165
168
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const DEFAULT_CHECK_INTERVAL = 15_000;
|
|
2
|
+
const DEFAULT_MIN_GAP = 600_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Safety net for the Hypercore replication stall (block requests that a peer never answers; the install-time
|
|
6
|
+
* patch in scripts/hypercore-stallfix/ is the actual fix). Signal: at every check for `timeout` ms this node
|
|
7
|
+
* knew signed system data it had not applied (base.core.core.state.length > base.core.length) while its
|
|
8
|
+
* confirmed length (base.view.core.signedLength) did not move. Then it closes ALL connections of the MSB swarm
|
|
9
|
+
* once, pinned validator links included, and lets discovery reconnect: the transport reset a restart does,
|
|
10
|
+
* without a restart. At most once per `minGap`, and only after a new full `timeout` window.
|
|
11
|
+
*
|
|
12
|
+
* Never runs on an indexer. It only closes connections: roles, transactions, balances and state are untouched.
|
|
13
|
+
*/
|
|
14
|
+
export class ReplicationStallGuard {
|
|
15
|
+
#base;
|
|
16
|
+
#getSwarm;
|
|
17
|
+
#refresh;
|
|
18
|
+
#timeout;
|
|
19
|
+
#interval;
|
|
20
|
+
#minGap;
|
|
21
|
+
#now;
|
|
22
|
+
#log;
|
|
23
|
+
#timer = null;
|
|
24
|
+
#mark = null;
|
|
25
|
+
#stalledSince = null;
|
|
26
|
+
#lastReconnectAt = null;
|
|
27
|
+
#lastClosed = 0;
|
|
28
|
+
#reconnects = 0;
|
|
29
|
+
#last = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} base Autobase instance of MSB
|
|
33
|
+
* @param {() => object|null} getSwarm returns the MSB Hyperswarm (or null)
|
|
34
|
+
* @param {object} options
|
|
35
|
+
*/
|
|
36
|
+
constructor(base, getSwarm, {
|
|
37
|
+
timeout,
|
|
38
|
+
interval = DEFAULT_CHECK_INTERVAL,
|
|
39
|
+
minGap = DEFAULT_MIN_GAP,
|
|
40
|
+
refresh = null,
|
|
41
|
+
now = Date.now,
|
|
42
|
+
log = console.log
|
|
43
|
+
} = {}) {
|
|
44
|
+
this.#base = base;
|
|
45
|
+
this.#getSwarm = getSwarm;
|
|
46
|
+
this.#refresh = refresh;
|
|
47
|
+
this.#timeout = timeout;
|
|
48
|
+
this.#interval = Number.isSafeInteger(timeout) && timeout > 0 ? Math.min(interval, timeout) : interval;
|
|
49
|
+
this.#minGap = minGap;
|
|
50
|
+
this.#now = now;
|
|
51
|
+
this.#log = log;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get enabled() {
|
|
55
|
+
return Number.isSafeInteger(this.#timeout) && this.#timeout > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start() {
|
|
59
|
+
if (!this.enabled || this.#timer !== null) return;
|
|
60
|
+
this.#timer = setInterval(() => { this.check(); }, this.#interval);
|
|
61
|
+
this.#timer.unref?.();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop() {
|
|
65
|
+
if (this.#timer !== null) clearInterval(this.#timer);
|
|
66
|
+
this.#timer = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
status() {
|
|
70
|
+
const last = this.#last;
|
|
71
|
+
let state = 'ok';
|
|
72
|
+
if (this.#stalledSince !== null) state = 'stalled';
|
|
73
|
+
else if (this.#mark !== null) state = 'behind';
|
|
74
|
+
return {
|
|
75
|
+
enabled: this.enabled,
|
|
76
|
+
state,
|
|
77
|
+
confirmedLength: last?.confirmed ?? null,
|
|
78
|
+
systemLength: last?.systemLength ?? null,
|
|
79
|
+
systemSignedLength: last?.systemSignedLength ?? null,
|
|
80
|
+
behindSince: this.#mark?.since ?? null,
|
|
81
|
+
stalledSince: this.#stalledSince,
|
|
82
|
+
reconnects: this.#reconnects,
|
|
83
|
+
lastReconnectAt: this.#lastReconnectAt,
|
|
84
|
+
lastReconnectClosed: this.#lastClosed
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#emit(event, fields) {
|
|
89
|
+
try { this.#log(`[MSB] ${JSON.stringify({ event, ...fields })}`); } catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#sample() {
|
|
93
|
+
const base = this.#base;
|
|
94
|
+
const confirmed = base?.view?.core?.signedLength;
|
|
95
|
+
const systemLength = base?.core?.length;
|
|
96
|
+
const systemSignedLength = base?.core?.core?.state?.length;
|
|
97
|
+
if (![confirmed, systemLength, systemSignedLength].every(Number.isSafeInteger)) return null;
|
|
98
|
+
return { confirmed, systemLength, systemSignedLength };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {boolean} true when the connections were closed */
|
|
102
|
+
check() {
|
|
103
|
+
const base = this.#base;
|
|
104
|
+
if (!this.enabled || !base || base.closing || base.closed || base.isIndexer) {
|
|
105
|
+
this.#mark = null;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const s = this.#sample();
|
|
109
|
+
if (s === null) return false;
|
|
110
|
+
this.#last = s;
|
|
111
|
+
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
const ahead = s.systemSignedLength > s.systemLength;
|
|
114
|
+
const moved = this.#mark !== null && this.#mark.confirmed !== s.confirmed;
|
|
115
|
+
|
|
116
|
+
if (this.#stalledSince !== null && (!ahead || moved)) {
|
|
117
|
+
this.#emit('msb_replication_recovered', {
|
|
118
|
+
confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
119
|
+
systemSignedLength: s.systemSignedLength, stalledMs: now - this.#stalledSince
|
|
120
|
+
});
|
|
121
|
+
this.#stalledSince = null;
|
|
122
|
+
}
|
|
123
|
+
if (!ahead) {
|
|
124
|
+
this.#mark = null;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (this.#mark === null || moved || now < this.#mark.since) {
|
|
128
|
+
this.#mark = { confirmed: s.confirmed, since: now };
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const elapsed = now - this.#mark.since;
|
|
133
|
+
if (elapsed < this.#timeout) return false;
|
|
134
|
+
|
|
135
|
+
if (this.#stalledSince === null) {
|
|
136
|
+
this.#stalledSince = this.#mark.since;
|
|
137
|
+
this.#emit('msb_replication_stalled', {
|
|
138
|
+
confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
139
|
+
systemSignedLength: s.systemSignedLength, stalledMs: elapsed
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (this.#lastReconnectAt !== null && now - this.#lastReconnectAt < this.#minGap) return false;
|
|
143
|
+
|
|
144
|
+
const closed = this.#reconnectAll();
|
|
145
|
+
this.#lastReconnectAt = now;
|
|
146
|
+
this.#lastClosed = closed;
|
|
147
|
+
this.#reconnects++;
|
|
148
|
+
this.#emit('msb_replication_reconnect_all', {
|
|
149
|
+
closedConnections: closed, confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
150
|
+
systemSignedLength: s.systemSignedLength, stalledMs: now - this.#stalledSince, reconnects: this.#reconnects
|
|
151
|
+
});
|
|
152
|
+
// the next action needs a new full window
|
|
153
|
+
this.#mark = { confirmed: s.confirmed, since: now };
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#reconnectAll() {
|
|
158
|
+
const swarm = this.#getSwarm?.();
|
|
159
|
+
if (!swarm || swarm.destroyed || swarm.suspended || !swarm.connections) return 0;
|
|
160
|
+
let closed = 0;
|
|
161
|
+
for (const connection of [...swarm.connections]) {
|
|
162
|
+
if (connection.destroyed || connection.destroying) continue;
|
|
163
|
+
try {
|
|
164
|
+
connection.destroy();
|
|
165
|
+
closed++;
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
if (this.#refresh !== null) {
|
|
169
|
+
try {
|
|
170
|
+
Promise.resolve(this.#refresh()).catch(() => {});
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return closed;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default ReplicationStallGuard;
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import {safeDecodeApplyOperation, safeEncodeApplyOperation} from "./utils/protob
|
|
|
29
29
|
import PartialTransactionValidator from "./core/network/protocols/shared/validators/PartialTransactionValidator.js";
|
|
30
30
|
import PartialTransferValidator from "./core/network/protocols/shared/validators/PartialTransferValidator.js";
|
|
31
31
|
import { BroadcastError, ValidationError } from "./utils/errors.js";
|
|
32
|
+
import { ReplicationStallGuard } from "./core/network/services/ReplicationStallGuard.js";
|
|
33
|
+
import { hypercoreStallfixLine, hypercoreStallfixStatus } from "./utils/hypercoreStallfix.js";
|
|
32
34
|
|
|
33
35
|
export class MainSettlementBus extends ReadyResource {
|
|
34
36
|
#store;
|
|
@@ -37,6 +39,7 @@ export class MainSettlementBus extends ReadyResource {
|
|
|
37
39
|
#state;
|
|
38
40
|
#isClosing = false;
|
|
39
41
|
#config
|
|
42
|
+
#replicationGuard = null;
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* @param {import("./config/config.js").Config} config
|
|
@@ -87,6 +90,13 @@ export class MainSettlementBus extends ReadyResource {
|
|
|
87
90
|
this.#wallet,
|
|
88
91
|
);
|
|
89
92
|
|
|
93
|
+
console.log(hypercoreStallfixLine('MSB', this.#state.base.core));
|
|
94
|
+
this.#replicationGuard = new ReplicationStallGuard(this.#state.base, () => this.#network?.swarm ?? null, {
|
|
95
|
+
timeout: this.#config.replicationStallTimeout,
|
|
96
|
+
refresh: () => this.#network?.swarm?.status?.(this.#config.channel)?.refresh?.()
|
|
97
|
+
});
|
|
98
|
+
this.#replicationGuard.start();
|
|
99
|
+
|
|
90
100
|
const adminEntry = await this.#state.getAdminEntry();
|
|
91
101
|
await this.#setUpRoleAutomatically(adminEntry);
|
|
92
102
|
|
|
@@ -106,6 +116,7 @@ export class MainSettlementBus extends ReadyResource {
|
|
|
106
116
|
console.log("Closing everything gracefully... This may take a moment.");
|
|
107
117
|
|
|
108
118
|
this.#isClosing = true;
|
|
119
|
+
this.#replicationGuard?.stop();
|
|
109
120
|
await this.#network.close();
|
|
110
121
|
|
|
111
122
|
await sleep(100);
|
|
@@ -121,6 +132,18 @@ export class MainSettlementBus extends ReadyResource {
|
|
|
121
132
|
await sleep(100);
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Replication health: whether the loaded Hypercore carries the stall fix, and the stall guard state
|
|
137
|
+
* ('ok' | 'behind' | 'stalled'). Read-only.
|
|
138
|
+
*/
|
|
139
|
+
replicationStatus() {
|
|
140
|
+
return {
|
|
141
|
+
hypercoreStallfix: hypercoreStallfixStatus(this.#state?.base?.core, this.#store),
|
|
142
|
+
stall: this.#replicationGuard?.status() ?? null,
|
|
143
|
+
connections: this.#network?.swarm?.connections?.size ?? 0
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
async destroy() {
|
|
125
148
|
return await this.close();
|
|
126
149
|
}
|
|
@@ -316,6 +339,11 @@ export class MainSettlementBus extends ReadyResource {
|
|
|
316
339
|
console.log("---------- flags ----------");
|
|
317
340
|
console.log(`isIndexer: ${this.#state?.isIndexer?.() ?? "unset"}`);
|
|
318
341
|
console.log(`isWriter: ${this.#state?.isWritable?.() ?? "unset"}`);
|
|
342
|
+
const replication = this.replicationStatus();
|
|
343
|
+
console.log("---------- replication ----------");
|
|
344
|
+
console.log(`hypercoreStallfix: ${replication.hypercoreStallfix.patched ? replication.hypercoreStallfix.marker : "NOT ACTIVE"}` +
|
|
345
|
+
` (expired requests: ${replication.hypercoreStallfix.expiredRequests ?? "n/a"}, timed pauses: ${replication.hypercoreStallfix.invalidOperationPauses ?? "n/a"})`);
|
|
346
|
+
console.log(`replicationStall: ${replication.stall?.state ?? "off"} (reconnects: ${replication.stall?.reconnects ?? 0})`);
|
|
319
347
|
} catch (error) {
|
|
320
348
|
console.error("Error during DAG monitoring:", error.message);
|
|
321
349
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Runtime view of the install-time Hypercore patch (scripts/hypercore-stallfix/). It looks at the Replicator class
|
|
2
|
+
// of a live core, so it reports the Hypercore copy that is actually loaded, not the one npm resolution suggests.
|
|
3
|
+
export const HYPERCORE_STALLFIX_MARKER = 'openmayhem-stallfix-v2';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} core a Hypercore session (for MSB: state.base.core, the system view)
|
|
7
|
+
* @param {object|null} store optional Corestore; when given, request counters are summed over its open cores
|
|
8
|
+
*/
|
|
9
|
+
export function hypercoreStallfixStatus(core, store = null) {
|
|
10
|
+
const Replicator = core?.core?.replicator?.constructor ?? null;
|
|
11
|
+
const marker = typeof Replicator?.STALLFIX === 'string' ? Replicator.STALLFIX : null;
|
|
12
|
+
const status = {
|
|
13
|
+
patched: marker === HYPERCORE_STALLFIX_MARKER,
|
|
14
|
+
marker,
|
|
15
|
+
expiredRequests: null,
|
|
16
|
+
notAvailableSent: null,
|
|
17
|
+
notAvailableSuppressed: null,
|
|
18
|
+
invalidOperationPauses: null,
|
|
19
|
+
invalidOperationPermanent: null
|
|
20
|
+
};
|
|
21
|
+
if (marker === null) return status;
|
|
22
|
+
|
|
23
|
+
let expired = 0;
|
|
24
|
+
let sent = 0;
|
|
25
|
+
let suppressed = 0;
|
|
26
|
+
let opPauses = 0;
|
|
27
|
+
let opPermanent = 0;
|
|
28
|
+
const cores = store?.cores?.map;
|
|
29
|
+
const list = cores && typeof cores.values === 'function' ? cores.values() : [core.core];
|
|
30
|
+
for (const c of list) {
|
|
31
|
+
const stats = c?.replicator?._sfStats;
|
|
32
|
+
if (!stats) continue;
|
|
33
|
+
expired += stats.expired;
|
|
34
|
+
sent += stats.notAvailableSent;
|
|
35
|
+
suppressed += stats.notAvailableSuppressed;
|
|
36
|
+
opPauses += stats.invalidOperationPauses ?? 0; // 11.18.3 only (11.8.3 never pauses on INVALID_OPERATION)
|
|
37
|
+
opPermanent += stats.invalidOperationPermanent ?? 0;
|
|
38
|
+
}
|
|
39
|
+
status.expiredRequests = expired;
|
|
40
|
+
status.notAvailableSent = sent;
|
|
41
|
+
status.notAvailableSuppressed = suppressed;
|
|
42
|
+
status.invalidOperationPauses = opPauses;
|
|
43
|
+
status.invalidOperationPermanent = opPermanent;
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One startup line for logs/alerts. */
|
|
48
|
+
export function hypercoreStallfixLine(label, core) {
|
|
49
|
+
const { patched, marker } = hypercoreStallfixStatus(core);
|
|
50
|
+
if (patched) return `[${label}] Hypercore replication stall fix: active (${marker}); closed-stream guard #911 active`;
|
|
51
|
+
return `[${label}] WARNING: Hypercore replication stall fix NOT active in the loaded hypercore` +
|
|
52
|
+
` (marker: ${marker ?? 'none'}). Reinstall without --ignore-scripts or run scripts/hypercore-stallfix/apply.mjs.`;
|
|
53
|
+
}
|