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.
@@ -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
+ }
@@ -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
+ }