@mmmbuto/nexuscrew 0.9.57 → 0.9.58

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.
@@ -14,6 +14,7 @@
14
14
  const BACKOFF_MIN_MS = 5000;
15
15
  const BACKOFF_MAX_MS = 60000;
16
16
  const IDLE_TIMEOUT_MS = 60000;
17
+ const ERROR_BODY_TIMEOUT_MS = 5000;
17
18
  const MAX_CONSECUTIVE_RESETS = 3;
18
19
  // `resync-exhausted` ferma il loop ma non deve parcheggiare la view
19
20
  // per sempre. Al tappo la view prende un cooldown a gradini — 30 s → 2 min →
@@ -68,6 +69,12 @@ function contentTypeOf(r) {
68
69
  }
69
70
  }
70
71
 
72
+ function isNamedFeedReason(reason) {
73
+ return ['shape', 'owner-mismatch', 'snapshot-oversize', 'snapshot-element-oversize',
74
+ 'snapshot-resync-required', 'snapshot-content-type'].includes(reason)
75
+ || (typeof reason === 'string' && reason.startsWith('snapshot-schema:'));
76
+ }
77
+
71
78
  function createEventFeedClient(opts = {}) {
72
79
  // deps: { nodesPath, token(), eventsHub, now, fetchImpl, log }
73
80
  const fetchImpl = opts.fetchImpl || fetch;
@@ -86,9 +93,40 @@ function createEventFeedClient(opts = {}) {
86
93
  let stopped = false;
87
94
  let generation = 0;
88
95
 
89
- function backoffMs(peer) {
90
- const fails = (peer._fails || 0) + 1;
91
- peer._fails = fails;
96
+ const retries = new Map(); // ownerId -> persistent retry state
97
+ const configurations = new Map();
98
+ function retryFor(ownerId) {
99
+ if (!retries.has(ownerId)) retries.set(ownerId, { fails: 0, nextRetryAt: 0, timer: null });
100
+ return retries.get(ownerId);
101
+ }
102
+ function clearRetry(ownerId, reset = false) {
103
+ const retry = retries.get(ownerId);
104
+ if (!retry) return;
105
+ if (retry.timer) clearTimeout(retry.timer);
106
+ retry.timer = null;
107
+ if (reset) { retry.fails = 0; retry.nextRetryAt = 0; }
108
+ }
109
+ function active(ownerId, slot) {
110
+ return !stopped && running.get(ownerId) === slot && !slot.controller.signal.aborted;
111
+ }
112
+ async function withDeadline(slot, milliseconds, operation) {
113
+ const controller = slot ? slot.controller : new AbortController();
114
+ const signal = controller.signal;
115
+ let rejectAbort;
116
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('acquisition aborted')); });
117
+ signal.addEventListener('abort', rejectAbort, { once: true });
118
+ const timer = setTimeout(() => controller.abort(), milliseconds);
119
+ try {
120
+ if (signal.aborted) throw new Error('acquisition aborted');
121
+ return await Promise.race([operation(signal), aborted]);
122
+ } finally {
123
+ clearTimeout(timer);
124
+ signal.removeEventListener('abort', rejectAbort);
125
+ }
126
+ }
127
+ function backoffMs(ownerId) {
128
+ const retry = retryFor(ownerId);
129
+ const fails = ++retry.fails;
92
130
  const base = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * Math.pow(2, fails - 1));
93
131
  return Math.floor(base / 2 + Math.random() * (base / 2));
94
132
  }
@@ -296,7 +334,7 @@ function createEventFeedClient(opts = {}) {
296
334
  return Math.max(oldest + INGRESS_WINDOW_MS, t) + INGRESS_BLOCK_BACKOFF_MS;
297
335
  }
298
336
 
299
- async function streamOnce(peer, ownerId, store) {
337
+ async function streamOnce(peer, ownerId, store, slot) {
300
338
  const view = viewFor(ownerId);
301
339
  // The deadline expired: the peer is admitted again and the block is closed
302
340
  // by the transition that reopens the stream, not by a silent timeout.
@@ -305,15 +343,16 @@ function createEventFeedClient(opts = {}) {
305
343
  view.ingressBlockReason = null;
306
344
  }
307
345
  const after = view.cursor ? `?after=${encodeURIComponent(view.cursor)}` : '';
308
- const ctrl = new AbortController();
309
- running.set(ownerId, { abort: () => ctrl.abort() });
310
- let timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS); // 60 s without bytes
346
+ const ctrl = slot.controller;
311
347
  try {
312
- const r = await fetchImpl(routeUrl(peer, '/event-feed', after), {
313
- headers: identityHeaders(peer, store), signal: ctrl.signal,
314
- });
315
- if (r.status === 403 && (await r.json().catch(() => ({}))).reason === 'events-disabled') {
316
- view.stale = true; return;
348
+ const r = await withDeadline(slot, IDLE_TIMEOUT_MS, (signal) => fetchImpl(routeUrl(peer, '/event-feed', after), {
349
+ headers: identityHeaders(peer, store), signal,
350
+ }));
351
+ if (!active(ownerId, slot)) return;
352
+ if (r.status === 403) {
353
+ const body = await withDeadline(slot, ERROR_BODY_TIMEOUT_MS, () => r.json().catch(() => ({})));
354
+ if (!active(ownerId, slot)) return;
355
+ if (body.reason === 'events-disabled') { view.stale = true; return; }
317
356
  }
318
357
  if (r.status === 409) { // reset-required / cursor-future: resync from a fresh snapshot
319
358
  view.cursor = null;
@@ -321,17 +360,15 @@ function createEventFeedClient(opts = {}) {
321
360
  return;
322
361
  }
323
362
  if (!r.ok) { view.stale = true; throw new Error(`event-feed HTTP ${r.status}`); }
324
- peer._fails = 0;
363
+ if (!active(ownerId, slot)) return;
325
364
  const reader = r.body.getReader();
326
365
  const dec = new TextDecoder();
327
366
  let buffer = '';
328
367
  for (;;) {
329
- const { done, value } = await reader.read();
330
- if (done) break;
331
- // ONE idle timer, rearmed on every chunk: a stale armed timeout must
332
- // never survive next to a new one.
333
- clearTimeout(timer);
334
- timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS);
368
+ const { done, value } = await withDeadline(slot, IDLE_TIMEOUT_MS, () => reader.read());
369
+ if (!active(ownerId, slot)) return;
370
+ if (done) throw new Error('event-feed EOF');
371
+ // Each read owns one deadline; no previous timeout survives the chunk.
335
372
  const chunk = dec.decode(value, { stream: true });
336
373
  buffer += chunk;
337
374
  let idx;
@@ -376,35 +413,42 @@ function createEventFeedClient(opts = {}) {
376
413
  view.consecutiveResets = 0; // a usable frame: the streak is over
377
414
  view.resyncExhaustions = 0; // and a working stream: the cooldown ladder back to the first step
378
415
  view.cursor = `${epochStr}:${sStr}`;
416
+ clearRetry(ownerId, true);
379
417
  reemit(envelope);
380
418
  }
381
419
  }
382
420
  } finally {
383
- clearTimeout(timer);
384
- running.delete(ownerId);
421
+ // The acquisition slot owns the connection throughout its lifecycle.
422
+ ctrl.abort();
385
423
  }
386
424
  }
387
425
 
388
426
  // Capability BEFORE subscribing: an owner without the feed capability makes
389
427
  // the view unsupported — no fallback, no infinite backoff.
390
- async function checkCapability(peer, ownerId) {
428
+ async function checkCapability(peer, ownerId, slot) {
391
429
  const view = viewFor(ownerId);
392
430
  if (view.capabilityChecked) return true;
393
431
  // The health endpoint of the OWNER is probed directly (same pattern as
394
432
  // the tunnel health probe): /federation/health with the peer's token.
395
- const r = await fetchImpl(`http://127.0.0.1:${peer.localPort}/federation/health`, {
396
- headers: { authorization: `Bearer ${peer.token}` },
433
+ const body = await withDeadline(slot, 5000, async (signal) => {
434
+ const r = await fetchImpl(`http://127.0.0.1:${peer.localPort}/federation/health`, {
435
+ headers: { authorization: `Bearer ${peer.token}` }, signal,
436
+ });
437
+ if (!r.ok) throw new Error(`health HTTP ${r.status}`);
438
+ return r.json();
397
439
  });
398
- if (!r.ok) { view.stale = true; throw new Error(`health HTTP ${r.status}`); }
399
- const body = await r.json();
440
+ if (!active(ownerId, slot)) throw new Error('obsolete acquisition');
400
441
  if (body.eventFeedV1 !== true || body.instanceId !== ownerId) {
401
442
  view.unsupported = true;
402
443
  view.lastError = 'event-feed-unsupported';
444
+ view.capabilityRecheckAt = now() + BACKOFF_MAX_MS;
403
445
  // One transition log, not one per tick: the poll gate keeps this
404
446
  // owner out from here on.
405
447
  log(`event-feed-client: ${ownerId.slice(0, 8)}… no feed capability, subscription refused`);
406
448
  return false;
407
449
  }
450
+ view.unsupported = false;
451
+ view.capabilityRecheckAt = null;
408
452
  view.capabilityChecked = true;
409
453
  return true;
410
454
  }
@@ -518,7 +562,7 @@ function createEventFeedClient(opts = {}) {
518
562
  // vista resta ferma e STALE con la sua causa — ma l'elenco asks torna
519
563
  // comunque alla porta, che è autorizzata a decidere: un campo che la
520
564
  // chiusura non legge non può tenere aperta una card chiusa dall'owner.
521
- async function snapshotOnce(peer, ownerId, store, { profile = 'view' } = {}) {
565
+ async function snapshotOnce(peer, ownerId, store, { profile = 'view', slot = null } = {}) {
522
566
  const view = viewFor(ownerId);
523
567
  // INVARIANTE (punto d'ingresso, per owner): al massimo uno snapshot per
524
568
  // finestra, da qualunque causa di resync. Se la finestra non è trascorsa
@@ -538,14 +582,14 @@ function createEventFeedClient(opts = {}) {
538
582
  view.lastSnapshotAt = now();
539
583
  view.nextSnapshotAllowedAt = null;
540
584
  const gen = generation;
541
- const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
542
- headers: identityHeaders(peer, store),
585
+ const { r, text } = await withDeadline(slot, 8000, async (signal) => {
586
+ const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
587
+ headers: identityHeaders(peer, store), signal,
588
+ });
589
+ if (!r.ok) throw new Error(`snapshot HTTP ${r.status}`);
590
+ return { r, text: await r.text() };
543
591
  });
544
- if (!r.ok) { view.stale = true; throw new Error(`snapshot HTTP ${r.status}`); }
545
- // Size cap BEFORE JSON.parse: a body over the server budget is refused on
546
- // sight, never parsed. La regola vive nel validatore unico; qui è la
547
- // scorciatoia che evita di pagare il parse di un corpo già rifiutato.
548
- const text = await r.text();
592
+ if ((slot && !active(ownerId, slot)) || gen !== generation) throw new Error('obsolete acquisition');
549
593
  const bytes = Buffer.byteLength(text, 'utf8');
550
594
  if (bytes > 3 * 1024 * 1024) {
551
595
  view.stale = true; view.lastError = 'snapshot-oversize';
@@ -576,6 +620,7 @@ function createEventFeedClient(opts = {}) {
576
620
  return { state: 'failed' };
577
621
  }
578
622
  applySnapshot(ownerId, snap, gen);
623
+ clearRetry(ownerId, true);
579
624
  return { state: 'applied', asks: snap.asks, appliedToView: true };
580
625
  }
581
626
 
@@ -586,15 +631,31 @@ function createEventFeedClient(opts = {}) {
586
631
  if (!store) return;
587
632
  const peers = (store.nodes || []).filter((n) => n && n.direction === 'outbound'
588
633
  && n.eventsReceive === true && n.token && n.localPort && n.nodeId);
634
+ const eligible = new Map(peers.map((peer) => [peer.nodeId, peer]));
635
+ const fingerprint = (peer) => JSON.stringify([peer.token, peer.localPort, peer.nodeId, peer.accessGrants, peer.accessPreset]);
636
+ for (const [ownerId, config] of configurations) {
637
+ const peer = eligible.get(ownerId);
638
+ if (!peer || fingerprint(peer) !== config) {
639
+ const slot = running.get(ownerId);
640
+ if (slot) { running.delete(ownerId); slot.abort(); }
641
+ clearRetry(ownerId, true);
642
+ configurations.delete(ownerId);
643
+ const view = views.get(ownerId);
644
+ if (view) { view.cursor = null; view.stale = true; view.capabilityChecked = false; view.unsupported = false; }
645
+ }
646
+ }
589
647
  await Promise.all(peers.map(async (peer) => {
590
648
  const ownerId = peer.nodeId;
649
+ configurations.set(ownerId, fingerprint(peer));
650
+ if (now() < retryFor(ownerId).nextRetryAt) return;
591
651
  if (running.has(ownerId)) return; // one loop per pair
592
652
  // Il guard vale per TUTTO il round, non solo per lo stream: due poll
593
653
  // sovrapposti (tick + backoff, o macchina carica) facevano due snapshot
594
654
  // nella stessa finestra e il secondo, senza il flag di recupero,
595
- // saltava il riarma del cooldown. streamOnce
596
- // sovrascrive lo slot col vero handle di abort.
597
- const slot = { abort: () => {} };
655
+ // saltava il riarma del cooldown. Lo stesso controller resta attivo
656
+ // dalla prima acquisizione fino alla chiusura dello stream.
657
+ const controller = new AbortController();
658
+ const slot = { controller, abort: () => controller.abort() };
598
659
  running.set(ownerId, slot);
599
660
  const view = viewFor(ownerId);
600
661
  try {
@@ -619,7 +680,11 @@ function createEventFeedClient(opts = {}) {
619
680
  recovery = true;
620
681
  log(`event-feed-client: ${ownerId.slice(0, 8)}… resync cooldown expired: one fresh snapshot attempt`);
621
682
  }
622
- if (view.unsupported || (view.ingressBlockedUntil || 0) > Date.now()) return;
683
+ if (view.unsupported) {
684
+ if (now() < view.capabilityRecheckAt) return;
685
+ view.capabilityChecked = false;
686
+ }
687
+ if ((view.ingressBlockedUntil || 0) > Date.now()) return;
623
688
  // La fase di ACQUISIZIONE del tentativo (capability + snapshot) ha UN
624
689
  // SOLO punto di uscita per gli esiti non riusciti: throw, timeout,
625
690
  // abort e 'failed' convergono tutti lì — non un ramo per tipo di
@@ -635,15 +700,16 @@ function createEventFeedClient(opts = {}) {
635
700
  let snapState = null; // null = tentativo non fatto (cursor già valido)
636
701
  try {
637
702
  if (!view.capabilityChecked) {
638
- const capOk = await checkCapability(peer, ownerId);
703
+ const capOk = await checkCapability(peer, ownerId, slot);
639
704
  if (!capOk) return; // senza la capability il gate blocca: nessun tentativo fatto
640
705
  }
641
706
  if (!view.cursor) {
642
- snapState = await snapshotOnce(peer, ownerId, store); // profilo view
707
+ snapState = await snapshotOnce(peer, ownerId, store, { slot }); // profilo view
643
708
  }
644
709
  } catch (e) {
645
710
  attemptError = e;
646
711
  }
712
+ if (stopped || running.get(ownerId) !== slot) return;
647
713
  if (attemptError || (snapState && snapState.state === 'failed')) {
648
714
  if (recovery) armResyncCooldown(view, attemptError ? 'recovery attempt failed' : 'recovery snapshot failed');
649
715
  }
@@ -651,21 +717,27 @@ function createEventFeedClient(opts = {}) {
651
717
  // Un tentativo fallito o posticipato ferma il round; NESSUN tentativo
652
718
  // (cursor già valido) va dritto allo stream.
653
719
  if (snapState && snapState.state !== 'applied') return;
654
- await streamOnce(peer, ownerId, store);
720
+ await streamOnce(peer, ownerId, store, slot);
655
721
  } catch (e) {
722
+ if (stopped || running.get(ownerId) !== slot) return;
723
+ view.cursor = null;
656
724
  view.stale = true;
657
725
  // A named feed reason (e.g. owner-mismatch) survives a later transport
658
726
  // error: it is the reason the operator needs to see.
659
- if (view.lastError !== 'owner-mismatch') {
727
+ if (!isNamedFeedReason(view.lastError)) {
660
728
  view.lastError = String((e && e.message) || e);
661
729
  }
662
- const delay = backoffMs(peer);
730
+ const delay = backoffMs(ownerId);
731
+ const retry = retryFor(ownerId);
732
+ clearRetry(ownerId);
733
+ retry.nextRetryAt = now() + delay;
663
734
  log(`event-feed-client: ${ownerId.slice(0, 8)}… retry in ${delay} ms (${view.lastError})`);
664
- setTimeout(() => { void poll(); }, delay);
735
+ retry.timer = setTimeout(() => { retry.timer = null; void poll(); }, delay);
736
+ if (typeof retry.timer.unref === 'function') retry.timer.unref();
665
737
  } finally {
666
- // Libera lo slot solo se è ancora il NOSTRO: se il round è arrivato a
667
- // streamOnce, quello ha già messo (e alla fine tolto) il suo slot vero.
738
+ // Un round obsoleto non libera mai lo slot della nuova generazione.
668
739
  if (running.get(ownerId) === slot) running.delete(ownerId);
740
+ slot.abort();
669
741
  }
670
742
  }));
671
743
  }
@@ -685,7 +757,9 @@ function createEventFeedClient(opts = {}) {
685
757
  function stop() {
686
758
  stopped = true; generation += 1;
687
759
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
760
+ for (const ownerId of retries.keys()) clearRetry(ownerId, true);
688
761
  for (const [, h] of running) { try { h.abort(); } catch (_) {} }
762
+ running.clear();
689
763
  }
690
764
 
691
765
  // Local aggregated read: connected views only, with the watermark the UI
@@ -18,6 +18,7 @@
18
18
  // Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
19
19
  // niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
20
20
  const express = require('express');
21
+ const accessPresets = require('../nodes/access-presets.js');
21
22
  const { createAskAnswerService } = require('./ask-answer-service.js');
22
23
  // Gli insiemi degli esiti vivono nella coda: il fan-out decide COSA accodare con
23
24
  // la stessa definizione con cui la coda decide cosa ritentare. Due copie della
@@ -116,23 +117,33 @@ function replyLabel(cfg) {
116
117
  // peer autorizzati di questo nodo. L'enumerazione sta qui e non nel dispatcher
117
118
  // perche' il dispatcher conosce solo il target esatto: una wildcard implicita
118
119
  // sarebbe un modo per parlare a chi non si e' scelto.
119
- async function resolveFanTargets(peerTargets, target, self) {
120
- if (target !== undefined) return target === self ? [] : [String(target)];
121
- if (!peerTargets) return [];
120
+ async function resolveQualifiedFanTargets(peerTargets, target, self, log = () => {}) {
121
+ if (target === self) return { targets: [], known: true };
122
+ if (!peerTargets) return { targets: [], known: false };
122
123
  let list;
123
- try { list = await peerTargets(); } catch (_) { return []; }
124
- if (!Array.isArray(list)) return [];
124
+ try { list = await peerTargets(); } catch (_) { return { targets: [], known: false }; }
125
+ if (!Array.isArray(list)) return { targets: [], known: false };
125
126
  const seen = new Set();
126
127
  const out = [];
128
+ let skipped = 0;
127
129
  for (const raw of list) {
128
130
  const id = raw && typeof raw === 'object' ? (raw.nodeId || raw.instanceId) : raw;
129
131
  const clean = String(id || '');
130
- if (!TARGET_RE.test(clean)) continue;
131
- if (self && clean === self) continue;
132
- if (seen.has(clean)) continue;
133
- seen.add(clean); out.push(clean);
132
+ if (!TARGET_RE.test(clean) || (self && clean === self) || seen.has(clean)) continue;
133
+ if (target !== undefined && clean !== String(target)) continue;
134
+ seen.add(clean);
135
+ // A routed destination is qualified by this node's own direct first hop.
136
+ const view = accessPresets.grantsOf(raw && typeof raw === 'object' ? (raw.firstHop || raw) : null);
137
+ if (!view.configured || view.label !== 'admin') { skipped++; continue; }
138
+ out.push(clean);
134
139
  }
135
- return out;
140
+ if (target !== undefined && !seen.has(String(target))) skipped++;
141
+ if (skipped) { try { log(`ask fan-out skipped: not admin (${skipped})`); } catch (_) {} }
142
+ return { targets: out, known: true };
143
+ }
144
+
145
+ async function resolveFanTargets(peerTargets, target, self, log = () => {}) {
146
+ return (await resolveQualifiedFanTargets(peerTargets, target, self, log)).targets;
136
147
  }
137
148
 
138
149
  // La CHIUSURA di un ask segue la stessa strada dell'andata: chi ha ricevuto la
@@ -154,10 +165,13 @@ function createClosureFanout({
154
165
  const self = localNodeId();
155
166
  // `targets` esplicito = ritentativo: si va SOLO verso i pendenti, non di
156
167
  // nuovo verso tutti. Senza, si enumerano i peer autorizzati come sempre.
157
- const targets2 = Array.isArray(targets) && targets.length
158
- ? targets
159
- : await resolveFanTargets(peerTargets, undefined, self);
160
- const out = [];
168
+ const inventory = await resolveQualifiedFanTargets(peerTargets, undefined, self, log);
169
+ const eligible = inventory.targets;
170
+ const allowed = new Set(eligible);
171
+ const targets2 = Array.isArray(targets) && targets.length ? targets.filter(target => allowed.has(target)) : eligible;
172
+ const out = Array.isArray(targets) ? targets.filter(target => !allowed.has(target))
173
+ .map(target => ({ target, status: inventory.known ? 'refused' : 'unknown',
174
+ reason: inventory.known ? 'ask-target-not-admin' : 'ask-targets-unavailable' })) : [];
161
175
  for (const target of targets2) {
162
176
  try {
163
177
  const r = await dispatcher.dispatch({
@@ -191,7 +205,7 @@ function createClosureFanout({
191
205
  }
192
206
 
193
207
  function notifyRoutes({
194
- cfg, notifier, push, asks, paste, sessionExists,
208
+ cfg, notifier, push, asks, submit, sessionExists,
195
209
  fleetP = null, instanceId = null, identityMode = 'legacy',
196
210
  // Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
197
211
  // route resta esattamente quella locale di prima: nessun percorso nuovo si
@@ -215,7 +229,8 @@ function notifyRoutes({
215
229
  // not inject one: the local route and the federated surface must never drift
216
230
  // into two different implementations.
217
231
  const askService = answerService || createAskAnswerService({
218
- asks, paste,
232
+ asks,
233
+ submit, receipts,
219
234
  labelPrefix: replyLabel(cfg),
220
235
  onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
221
236
  });
@@ -384,7 +399,7 @@ function notifyRoutes({
384
399
  // perche' il dispatcher non conosce i broadcast: target esatto soltanto, e
385
400
  // deve restare cosi' (una wildcard implicita e' un modo per parlare a chi non
386
401
  // si e' scelto).
387
- const askFanTargets = (target, self) => resolveFanTargets(peerTargets, target, self);
402
+ const askFanTargets = (target, self) => resolveFanTargets(peerTargets, target, self, log);
388
403
  const closureFanout = closureFanoutDep || createClosureFanout({ dispatcher, peerTargets, localNodeId, log, retry: closureRetry });
389
404
 
390
405
  // da revisione: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande