trac-msb 0.2.24 → 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
+ }
@@ -219,6 +219,16 @@ export class Config {
219
219
  return this.#config.validatorHealthCheckFailureThreshold
220
220
  }
221
221
 
222
+ get confirmationStallTimeout() {
223
+ if (this.#isOverriden('confirmationStallTimeout')) return this.#options.confirmationStallTimeout
224
+ return this.#config.confirmationStallTimeout
225
+ }
226
+
227
+ get replicationStallTimeout() {
228
+ if (this.#isOverriden('replicationStallTimeout')) return this.#options.replicationStallTimeout
229
+ return this.#config.replicationStallTimeout
230
+ }
231
+
222
232
  get maxPendingRequestsInPendingRequestsService() {
223
233
  return this.#config.maxPendingRequestsInPendingRequestsService
224
234
  }
package/src/config/env.js CHANGED
@@ -57,6 +57,8 @@ const configData = {
57
57
  txPoolSize: 1000, // size of transaction pool
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
+ 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)
60
62
  storesDirectory: 'stores/',
61
63
  storeName: 'testnet',
62
64
  },
@@ -107,6 +109,8 @@ const configData = {
107
109
  txPoolSize: 1000, // size of transaction pool
108
110
  validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
109
111
  validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
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)
110
114
  storesDirectory: 'stores/',
111
115
  storeName: 'mainnet',
112
116
  },
@@ -157,6 +161,8 @@ const configData = {
157
161
  txPoolSize: 1000, // size of transaction pool
158
162
  validatorHealthCheckInterval: 1_000, // How often to check validator health (ms)
159
163
  validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
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)
160
166
  storesDirectory: 'stores/',
161
167
  storeName: 'development',
162
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;
@@ -0,0 +1,82 @@
1
+ const DEFAULT_CHECK_INTERVAL = 15_000;
2
+
3
+ /**
4
+ * Autobase starts a follower's fast-forward only when the network's signed length is at least
5
+ * 16 entries past the follower's total length, unconfirmed entries included. After a writer is
6
+ * removed in the middle of a batch, a follower that already holds unconfirmed entries can stay
7
+ * above that mark: its confirmed length then stops for good, and a restart does not help.
8
+ *
9
+ * This watch forces one fast-forward when the confirmed length has not moved for `timeout` ms
10
+ * while unconfirmed entries wait. It never runs on an indexer and does not change how entries are
11
+ * applied.
12
+ */
13
+ export class ConfirmationWatch {
14
+ #base;
15
+ #timeout;
16
+ #interval;
17
+ #now;
18
+ #log;
19
+ #timer = null;
20
+ #mark = null;
21
+ #running = false;
22
+
23
+ constructor(base, { timeout, interval = DEFAULT_CHECK_INTERVAL, now = Date.now, log = console.log } = {}) {
24
+ this.#base = base;
25
+ this.#timeout = timeout;
26
+ this.#interval = Math.min(interval, timeout);
27
+ this.#now = now;
28
+ this.#log = log;
29
+ }
30
+
31
+ get enabled() {
32
+ return Number.isFinite(this.#timeout) && this.#timeout > 0;
33
+ }
34
+
35
+ start() {
36
+ if (!this.enabled || this.#timer !== null) return;
37
+ this.#timer = setInterval(() => { this.check().catch(() => {}) }, this.#interval);
38
+ this.#timer.unref?.();
39
+ }
40
+
41
+ stop() {
42
+ if (this.#timer !== null) clearInterval(this.#timer);
43
+ this.#timer = null;
44
+ }
45
+
46
+ /** @returns {Promise<boolean>} true when a fast-forward was forced */
47
+ async check() {
48
+ const base = this.#base;
49
+ if (this.#running || base.closing || base.isIndexer) return false;
50
+ if (base.isFastForwarding?.()) {
51
+ this.#mark = null;
52
+ return false;
53
+ }
54
+
55
+ const confirmed = base.view.core.signedLength;
56
+ const total = base.view.core.length;
57
+ const now = this.#now();
58
+
59
+ if (total <= confirmed) {
60
+ this.#mark = null;
61
+ return false;
62
+ }
63
+ if (this.#mark === null || this.#mark.confirmed !== confirmed) {
64
+ this.#mark = { confirmed, since: now };
65
+ return false;
66
+ }
67
+ if (now - this.#mark.since < this.#timeout) return false;
68
+
69
+ this.#running = true;
70
+ try {
71
+ this.#log(`[MSB] Confirmed length stuck at ${confirmed} (local ${total}) for ${Math.round((now - this.#mark.since) / 1000)} s; forcing a fast-forward`);
72
+ await base.forceFastForward();
73
+ this.#log(`[MSB] Fast-forward finished: confirmed ${base.view.core.signedLength}, local ${base.view.core.length}`);
74
+ } catch (error) {
75
+ this.#log(`[MSB] Fast-forward failed: ${error?.message ?? error}`);
76
+ } finally {
77
+ this.#running = false;
78
+ this.#mark = { confirmed: base.view.core.signedLength, since: this.#now() };
79
+ }
80
+ return true;
81
+ }
82
+ }
@@ -42,6 +42,7 @@ import { deepCopyBuffer } from '../../utils/buffer.js';
42
42
  import { Status } from './utils/transaction.js';
43
43
  import remote from 'hypercore/lib/fully-remote-proof.js'
44
44
  import PQueue from 'p-queue';
45
+ import { ConfirmationWatch } from './ConfirmationWatch.js';
45
46
 
46
47
  const OVERSIZED_BATCH_PENALTY_MULTIPLIER = BATCH_SIZE;
47
48
 
@@ -56,6 +57,7 @@ class State extends ReadyResource {
56
57
  #config
57
58
  #wallet
58
59
  #activeWriterCountCache = new Map();
60
+ #confirmationWatch = null;
59
61
 
60
62
  /**
61
63
  * @param {Corestore} store
@@ -96,10 +98,13 @@ class State extends ReadyResource {
96
98
  console.log("State initialization...")
97
99
  await this.#base.ready();
98
100
  this.#writingKey = this.#base.local.key;
101
+ this.#confirmationWatch = new ConfirmationWatch(this.#base, { timeout: this.#config.confirmationStallTimeout });
102
+ this.#confirmationWatch.start();
99
103
  }
100
104
 
101
105
  async _close() {
102
106
  console.log("State: closing gracefully...");
107
+ this.#confirmationWatch?.stop();
103
108
  if (this.#bee !== null) {
104
109
  await this.#bee.close();
105
110
  }