@mmmbuto/nexuscrew 0.9.52 → 0.9.54

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.
@@ -15,6 +15,16 @@ const BACKOFF_MIN_MS = 5000;
15
15
  const BACKOFF_MAX_MS = 60000;
16
16
  const IDLE_TIMEOUT_MS = 60000;
17
17
  const MAX_CONSECUTIVE_RESETS = 3;
18
+ // `resync-exhausted` ferma il loop ma non deve parcheggiare la view
19
+ // per sempre. Al tappo la view prende un cooldown a gradini — 30 s → 2 min →
20
+ // 5 min, poi fisso — e alla scadenza paga UN tentativo di snapshot fresco.
21
+ // Iniettabile per i test (timer veri, passi piccoli).
22
+ const RESYNC_COOLDOWN_STEPS_MS = [30 * 1000, 2 * 60 * 1000, 5 * 60 * 1000];
23
+ // INVARIANTE: al massimo UNO snapshot per finestra, per owner, da qualunque
24
+ // causa di resync (409, frame malformato, oversize, seq discontinua, errore
25
+ // di trasporto). È la garanzia strutturale: le vie singole possono cambiare,
26
+ // il tetto delle richieste no. Di default 30 s, iniettabile per i test.
27
+ const MIN_SNAPSHOT_INTERVAL_MS = 30 * 1000;
18
28
 
19
29
  // Ingress budgets: 120 frames/min and 1 MiB/min per owner, plus a separate
20
30
  // federated global window of 480 frames/min and 4 MiB/min. The counters are
@@ -36,11 +46,39 @@ const SNAPSHOT_MAX_CELLS = 1000;
36
46
  const SNAPSHOT_ELEMENT_MAX_BYTES = 16 * 1024;
37
47
  const SNAPSHOT_ID_MAX_CHARS = 64;
38
48
 
49
+ // Lo snapshot è JSON DICHIARATO: se la risposta dichiara un media type, questo
50
+ // dev'essere application/json (i parametri come charset sono ammessi). Un
51
+ // corpo che parsisce ma viaggia come text/plain non è la superficie dello
52
+ // snapshot — quale che sia il suo contenuto, non decide niente. La
53
+ // dichiarazione ASSENTE (proxy che spoglia gli header, trasporti degradati
54
+ // verso la view) resta tollerata: la regola colpisce chi dichiara un tipo
55
+ // sbagliato, non chi non dichiara.
56
+ function isJsonContentType(value) {
57
+ if (typeof value !== 'string') return false;
58
+ return value.split(';')[0].trim().toLowerCase() === 'application/json';
59
+ }
60
+
61
+ // Lettura difensiva dell'header: risposte senza headers (stub, trasporti
62
+ // degradati) valgono come dichiarazione ASSENTE, che il validatore tollera.
63
+ function contentTypeOf(r) {
64
+ try {
65
+ return r && r.headers && typeof r.headers.get === 'function' ? r.headers.get('content-type') : null;
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ }
70
+
39
71
  function createEventFeedClient(opts = {}) {
40
72
  // deps: { nodesPath, token(), eventsHub, now, fetchImpl, log }
41
73
  const fetchImpl = opts.fetchImpl || fetch;
42
74
  const now = opts.now || Date.now;
43
75
  const log = typeof opts.log === 'function' ? opts.log : () => {};
76
+ const cooldownSteps = Array.isArray(opts.resyncCooldownStepsMs) && opts.resyncCooldownStepsMs.length > 0
77
+ ? opts.resyncCooldownStepsMs
78
+ : RESYNC_COOLDOWN_STEPS_MS;
79
+ const minSnapshotIntervalMs = typeof opts.minSnapshotIntervalMs === 'number' && opts.minSnapshotIntervalMs >= 0
80
+ ? opts.minSnapshotIntervalMs
81
+ : MIN_SNAPSHOT_INTERVAL_MS;
44
82
  // ownerId -> view state {cursor, generation, asks, notifications, fleetState,
45
83
  // stale, lastError}; generation guards against obsolete fetches.
46
84
  const views = new Map();
@@ -62,14 +100,60 @@ function createEventFeedClient(opts = {}) {
62
100
  return views.get(ownerId);
63
101
  }
64
102
 
103
+ // Asks this node knows are CLOSED on the owner. The owner's snapshot can still
104
+ // carry one for a moment (its closing frame and our re-snapshot race), and the
105
+ // UI would re-import it — the card that comes back after the X.
106
+ // The tombstone carries a TTL and the view epoch it was made in: an id reused
107
+ // after an owner reset must NOT stay suppressed forever.
108
+ const ASK_TOMBSTONE_TTL_MS = 10 * 60 * 1000;
109
+ const askTombstones = new Map(); // `${ownerId}|${askId}` -> {at, epoch}
110
+
111
+ function askTombstoneKey(ownerId, askId) { return `${ownerId}|${askId}`; }
112
+
113
+ function noteAskClosed(ownerId, askId, epoch) {
114
+ if (!ownerId || !askId) return;
115
+ askTombstones.set(askTombstoneKey(ownerId, askId), { at: now(), epoch });
116
+ }
117
+
118
+ function askIsClosed(ownerId, askId, epoch) {
119
+ const key = askTombstoneKey(ownerId, askId);
120
+ const hit = askTombstones.get(key);
121
+ if (!hit) return false;
122
+ if (now() - hit.at > ASK_TOMBSTONE_TTL_MS) { askTombstones.delete(key); return false; }
123
+ if (typeof hit.epoch === 'number' && typeof epoch === 'number' && hit.epoch !== epoch) return false;
124
+ return true;
125
+ }
126
+
127
+ function dropAskFromView(ownerId, askId) {
128
+ const view = viewFor(ownerId);
129
+ view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
130
+ return view;
131
+ }
132
+
65
133
  // One resync round that ended WITHOUT a usable frame counts as a reset: a
66
134
  // peer that keeps asking to resync (409 reset-required / cursor-future, or a
67
135
  // frame from an unknown epoch) would otherwise be re-snapshotted forever.
68
136
  // Past the cap the view goes to the error state and the poll gate stops it;
69
137
  // a single healthy frame clears the streak.
138
+ // Arma (o riarma) il cooldown della view al gradino successivo della
139
+ // scala. Vale sia per l'esaurimento del tappo (noteReset) sia per un
140
+ // tentativo di recupero fallito (poll): in entrambi i casi la view torna a
141
+ // chiedere SOLO alla scadenza, un tentativo per scadenza.
142
+ function armResyncCooldown(view, why) {
143
+ view.resyncExhaustions = (view.resyncExhaustions || 0) + 1;
144
+ const step = cooldownSteps[Math.min(view.resyncExhaustions - 1, cooldownSteps.length - 1)];
145
+ view.resyncBlockedUntil = now() + step;
146
+ log(`event-feed-client: ${String(view.ownerId || '').slice(0, 8)}… ${why}: cooldown ${Math.round(step / 1000)} s (exhaustions ${view.resyncExhaustions})`);
147
+ }
148
+
70
149
  function noteReset(view) {
71
150
  view.consecutiveResets = (view.consecutiveResets || 0) + 1;
72
151
  if (view.consecutiveResets >= MAX_CONSECUTIVE_RESETS) {
152
+ // Il tappo ferma il loop, ma la view non resta parcheggiata: il
153
+ // cooldown cresce a ogni riesaurimento SENZA un frame sano in mezzo (una
154
+ // connessione che funziona riporta la scala al primo gradino, vedi
155
+ // streamOnce) e alla scadenza paga uno snapshot fresco (vedi poll).
156
+ armResyncCooldown(view, 'resync exhausted');
73
157
  view.stale = true;
74
158
  view.lastError = 'resync-exhausted';
75
159
  }
@@ -99,12 +183,24 @@ function createEventFeedClient(opts = {}) {
99
183
  const view = viewFor(ownerId);
100
184
  if (gen !== generation) return false;
101
185
  view.cursor = snap.cursor || null;
102
- view.asks = Array.isArray(snap.asks) ? snap.asks.map((a) => ({ ...a, ownerId })) : [];
186
+ // A snapshot taken before the owner processed a dismiss still carries the
187
+ // ask: the tombstone is what keeps it from coming back.
188
+ view.asks = Array.isArray(snap.asks)
189
+ ? snap.asks
190
+ .filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch))
191
+ .map((a) => ({ ...a, ownerId }))
192
+ : [];
103
193
  view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
104
194
  view.fleetState = snap.fleetState || null;
105
195
  view.askReplyAccess = snap.askReplyAccess === true;
106
196
  view.viewEpoch = snap.viewEpoch;
107
197
  view.stale = false;
198
+ // Lo snapshot fresco è la guarigione visibile: stale torna falso e
199
+ // l'ultimo errore cessa di essere mostrato. Lo streak NON si tocca qui:
200
+ // il conto dei reset misura i round SENZA frame sano, e ogni round di
201
+ // ricognizione inizia con uno snapshot — azzerarlo qui annullerebbe il
202
+ // tappo anti-reset. Lo sblocco dello streak sta alla scadenza del cooldown.
203
+ view.lastError = null;
108
204
  view.generation = gen;
109
205
  return true;
110
206
  }
@@ -133,6 +229,10 @@ function createEventFeedClient(opts = {}) {
133
229
  opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
134
230
  ask: { id: f.askId, question: f.question, options: f.options, session: f.session, ownerId: envelope.ownerId } });
135
231
  } else if (f.type === 'ask-closed') {
232
+ // The owner says the ask is closed: the VIEW drops it too, not only the
233
+ // UI, or the next state read hands the card back.
234
+ const closedView = dropAskFromView(envelope.ownerId, f.askId);
235
+ if (f.outcome === 'dismissed') noteAskClosed(envelope.ownerId, f.askId, closedView.viewEpoch);
136
236
  opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
137
237
  id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
138
238
  } else if (f.type === 'file-notice') {
@@ -241,7 +341,7 @@ function createEventFeedClient(opts = {}) {
241
341
  const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
242
342
  if (!dataLine) continue;
243
343
  // Frame cap BEFORE JSON.parse.
244
- if (dataLine.length > 16 * 1024) { log('event-feed-client: oversized frame, resync'); view.cursor = null; return; }
344
+ if (dataLine.length > 16 * 1024) { log('event-feed-client: oversized frame, resync'); view.cursor = null; noteReset(view); return; }
245
345
  // Ingress budget, counted per FRAME on the wire BEFORE it is parsed or
246
346
  // applied: over the cap this peer is disconnected by name and blocked
247
347
  // for the rest of its window, while the other owners keep streaming.
@@ -260,7 +360,7 @@ function createEventFeedClient(opts = {}) {
260
360
  return;
261
361
  }
262
362
  let envelope;
263
- try { envelope = JSON.parse(dataLine.slice(6)); } catch (_) { view.cursor = null; return; }
363
+ try { envelope = JSON.parse(dataLine.slice(6)); } catch (_) { view.cursor = null; noteReset(view); return; }
264
364
  // Owner mismatch on a frame: disconnect, never apply.
265
365
  if (envelope.ownerId !== ownerId) { log('event-feed-client: owner mismatch, disconnect'); return; }
266
366
  // Sequence continuity within the same epoch; anything else resyncs.
@@ -271,9 +371,10 @@ function createEventFeedClient(opts = {}) {
271
371
  noteReset(view);
272
372
  return;
273
373
  }
274
- if (view.lastSeq !== undefined && Number(sStr) !== view.lastSeq + 1) { view.cursor = null; return; }
374
+ if (view.lastSeq !== undefined && Number(sStr) !== view.lastSeq + 1) { view.cursor = null; noteReset(view); return; }
275
375
  view.lastSeq = Number(sStr);
276
376
  view.consecutiveResets = 0; // a usable frame: the streak is over
377
+ view.resyncExhaustions = 0; // and a working stream: the cooldown ladder back to the first step
277
378
  view.cursor = `${epochStr}:${sStr}`;
278
379
  reemit(envelope);
279
380
  }
@@ -308,56 +409,174 @@ function createEventFeedClient(opts = {}) {
308
409
  return true;
309
410
  }
310
411
 
311
- async function snapshotOnce(peer, ownerId, store) {
312
- const view = viewFor(ownerId);
313
- const gen = generation;
314
- const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
315
- headers: identityHeaders(peer, store),
316
- });
317
- if (!r.ok) { view.stale = true; throw new Error(`snapshot HTTP ${r.status}`); }
318
- // Size cap BEFORE JSON.parse: a body over the server budget is refused on
319
- // sight, never parsed.
320
- const text = await r.text();
321
- if (Buffer.byteLength(text, 'utf8') > 3 * 1024 * 1024) {
322
- view.stale = true; view.lastError = 'snapshot-oversize';
323
- return false;
324
- }
325
- const snap = JSON.parse(text);
326
- // Owner mismatch on the snapshot: disconnect, never apply.
327
- if (snap.ownerId !== ownerId) {
328
- view.stale = true; view.lastError = 'owner-mismatch';
329
- return false;
330
- }
331
- // Per-element sanity over EVERY list, not just the notify history: bounded
332
- // id, bounded element, and the count caps. A snapshot that breaks any of
333
- // them is refused whole — never applied in part.
412
+ // Il VALIDATORE unico dello snapshot: entrambe le vie (sottoscritta e non
413
+ // sottoscritta) passano da qui. Niente logica duplicata: una regola nuova
414
+ // vale per tutte le porte. Il pre-check della dimensione prima del parse
415
+ // resta nei chiamanti come scorciatoia (non si paga il parse di un corpo
416
+ // rifiutato): la regola vive qui.
417
+ //
418
+ // Due PROFILI dello stesso contratto, non due validatori:
419
+ // - 'decision' è ciò che rende l'elenco asks AUTOREVOLE per la chiusura
420
+ // degli alias importati (ownerId atteso, resyncRequired ben formato e
421
+ // non dichiarante, content-type dichiarato corretto, asks elenco di
422
+ // oggetti con id stringa 1..64, cap di pagina e per-elemento, cap del
423
+ // corpo). È il profilo della PORTA: la decisione di chiusura legge SOLO
424
+ // questi campi, e un campo che la decisione non legge non può tener
425
+ // aperta una card che l'owner ha chiuso (la vista che non si può
426
+ // applicare per colpa di cursor o notifications è un problema della
427
+ // vista, non una prova che l'ask sia ancora viva).
428
+ // - 'view' (default) aggiunge lo SCHEMA COMPLETO dei campi che la vista
429
+ // applica (cursor, viewEpoch, askReplyAccess, notifications, fleetState,
430
+ // i campi interni di ogni ask): qui un tipo sbagliato in QUALUNQUE
431
+ // punto non è mai 'ok' (reason 'snapshot-schema:<campo>') — lo snapshot
432
+ // non si applica, niente applicazione parziale.
433
+ // Produttore di riferimento: lib/notify/event-feed-routes.js buildSnapshot
434
+ // (emette sempre i campi della view e marca resyncRequired a ogni cap).
435
+ function validateSnapshot(ownerId, snap, bytes, contentType, profile = 'view') {
436
+ if (!snap || typeof snap !== 'object') return { ok: false, reason: 'shape' };
437
+ if (bytes > 3 * 1024 * 1024) return { ok: false, reason: 'snapshot-oversize' };
438
+ // ownerId: stringa E uguale all'atteso — un valore di altro tipo non può
439
+ // coincidere con l'id atteso, quindi owner-mismatch copre anche la forma.
440
+ if (snap.ownerId !== ownerId) return { ok: false, reason: 'owner-mismatch' };
441
+ // Uno snapshot incompleto è un floor, non un tutto: mai autorevole.
442
+ if (snap.resyncRequired === true) return { ok: false, reason: 'snapshot-resync-required' };
443
+ // Il trasporto dichiara il tipo: dichiarato ma non application/json non è
444
+ // la superficie dello snapshot. L'ASSENZA di dichiarazione è tollerata.
445
+ if (contentType != null && !isJsonContentType(contentType)) return { ok: false, reason: 'snapshot-content-type' };
446
+ const schema = (field) => ({ ok: false, reason: `snapshot-schema:${field}` });
447
+ // resyncRequired, quando c'è, è un booleano del produttore: 1, "true" o
448
+ // un oggetto sono un'altra versione del contratto, non un caso limite.
449
+ if (snap.resyncRequired !== undefined && typeof snap.resyncRequired !== 'boolean') return schema('resyncRequired');
450
+ // asks È l'elenco che decide la chiusura degli alias: OBBLIGATORIO, array.
451
+ // Senza questa richiesta uno snapshot senza asks passava come 'ok' e la
452
+ // riconciliazione chiudeva alias ancora aperti su un elenco che non c'era.
453
+ if (!Array.isArray(snap.asks)) return schema('asks');
334
454
  const boundedId = (id) => typeof id === 'string' && id.length > 0 && id.length <= SNAPSHOT_ID_MAX_CHARS;
335
455
  const boundedElement = (e) => !!e && typeof e === 'object'
336
456
  && Buffer.byteLength(JSON.stringify(e), 'utf8') <= SNAPSHOT_ELEMENT_MAX_BYTES;
337
- if (Array.isArray(snap.asks)
338
- && (snap.asks.length > SNAPSHOT_MAX_ASKS
339
- || !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id)))) {
340
- view.stale = true; view.lastError = 'snapshot-element-oversize';
341
- return false;
457
+ // Cap di pagina e per-elemento: identità e misura. Id non stringa o vuoto,
458
+ // elemento non oggetto o fuori budget → 'snapshot-element-oversize'
459
+ // (mapping storico invariato, il cap resta un cap). Il cap di pagina vale
460
+ // a quota PIENA (>= SNAPSHOT_MAX_ASKS): il produttore marca resyncRequired
461
+ // proprio a quella quota, quindi un elenco al cap SENZA marcatore non è
462
+ // una forma dell'owner e non prova l'assenza di nessuna domanda.
463
+ if (snap.asks.length >= SNAPSHOT_MAX_ASKS
464
+ || !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id))) {
465
+ return { ok: false, reason: 'snapshot-element-oversize' };
466
+ }
467
+ if (profile !== 'view') return { ok: true };
468
+ // — da qui in poi solo lo schema della VISTA —
469
+ // Flag e scalari col tipo esatto con cui la vista li applica (il
470
+ // produttore li emette sempre: un tipo diverso è un'altra versione, non
471
+ // un caso limite da tollerare). Assenti restano ammessi dove applySnapshot
472
+ // ha un default; sbagliati mai.
473
+ if (snap.cursor !== undefined && snap.cursor !== null && typeof snap.cursor !== 'string') return schema('cursor');
474
+ if (snap.viewEpoch !== undefined && snap.viewEpoch !== null && typeof snap.viewEpoch !== 'number') return schema('viewEpoch');
475
+ if (snap.askReplyAccess !== undefined && typeof snap.askReplyAccess !== 'boolean') return schema('askReplyAccess');
476
+ if (snap.notifications !== undefined && snap.notifications !== null
477
+ && !Array.isArray(snap.notifications)) return schema('notifications');
478
+ if (snap.fleetState !== undefined && snap.fleetState !== null
479
+ && (typeof snap.fleetState !== 'object' || Array.isArray(snap.fleetState))) return schema('fleetState');
480
+ if (snap.fleetState && typeof snap.fleetState === 'object') {
481
+ if (snap.fleetState.available !== undefined && typeof snap.fleetState.available !== 'boolean') return schema('fleetState');
482
+ if (snap.fleetState.cells !== undefined && !Array.isArray(snap.fleetState.cells)) return schema('fleetState');
342
483
  }
343
484
  if (Array.isArray(snap.notifications)
344
485
  && (snap.notifications.length > SNAPSHOT_MAX_NOTIFY
345
486
  || !snap.notifications.every((e) => boundedElement(e) && boundedId(e && e.eventId)))) {
346
- view.stale = true; view.lastError = 'snapshot-element-oversize';
347
- return false;
487
+ return { ok: false, reason: 'snapshot-element-oversize' };
348
488
  }
349
489
  if (snap.fleetState && typeof snap.fleetState === 'object') {
350
490
  const cells = Array.isArray(snap.fleetState.cells) ? snap.fleetState.cells : [];
351
491
  if (cells.length > SNAPSHOT_MAX_CELLS || !boundedElement(snap.fleetState)
352
492
  || !cells.every((c) => boundedElement(c) && boundedId(c && c.cell))) {
353
- view.stale = true; view.lastError = 'snapshot-element-oversize';
354
- return false;
493
+ return { ok: false, reason: 'snapshot-element-oversize' };
494
+ }
495
+ }
496
+ // Ogni ask: i campi che la card e la risposta usano davvero, col tipo del
497
+ // produttore. Assente è ammesso dove il produttore omette (options, ts);
498
+ // sbagliato mai — un numero al posto dell'id owner non deve diventare una
499
+ // chiave di risposta.
500
+ const askFieldsOk = (a) => (a.ownerAskId === undefined || (typeof a.ownerAskId === 'string' && a.ownerAskId.length > 0))
501
+ && (a.question === undefined || typeof a.question === 'string')
502
+ && (a.options === undefined || Array.isArray(a.options))
503
+ && (a.session === undefined || typeof a.session === 'string')
504
+ && (a.ts === undefined || typeof a.ts === 'number');
505
+ if (!snap.asks.every(askFieldsOk)) return schema('asks');
506
+ return { ok: true };
507
+ }
508
+
509
+ // Uno snapshot per finestra, per owner, da qualunque chiamante (poll della
510
+ // view o porta). ESITO strutturato: { state: 'applied' | 'failed' |
511
+ // 'skipped', asks?, appliedToView? }.
512
+ // - profilo 'view' (default, il poll): lo schema completo decide; solo un
513
+ // snapshot pienamente valido entra nella view ('applied' qui significa
514
+ // applicato davvero);
515
+ // - profilo 'decision' (la porta): decide il contratto della CHIUSURA. Se
516
+ // lo snapshot è autorevole per la decisione ma NON applicabile alla
517
+ // vista (cursor malformato, notifications di tipo sbagliato, …) la
518
+ // vista resta ferma e STALE con la sua causa — ma l'elenco asks torna
519
+ // comunque alla porta, che è autorizzata a decidere: un campo che la
520
+ // chiusura non legge non può tenere aperta una card chiusa dall'owner.
521
+ async function snapshotOnce(peer, ownerId, store, { profile = 'view' } = {}) {
522
+ const view = viewFor(ownerId);
523
+ // INVARIANTE (punto d'ingresso, per owner): al massimo uno snapshot per
524
+ // finestra, da qualunque causa di resync. Se la finestra non è trascorsa
525
+ // non parte nessuna richiesta e NON si tocca nulla: né la scala, né il
526
+ // cooldown di recupero — il campo della finestra è separato
527
+ // (nextSnapshotAllowedAt) proprio perché i due meccanismi non si
528
+ // mangino a vicenda. Lo stato resta stale e la visura in state() mostra
529
+ // quando il prossimo tentativo è lecito.
530
+ const since = view.lastSnapshotAt === undefined
531
+ ? Number.POSITIVE_INFINITY
532
+ : now() - view.lastSnapshotAt;
533
+ if (since < minSnapshotIntervalMs) {
534
+ view.stale = true;
535
+ view.nextSnapshotAllowedAt = view.lastSnapshotAt + minSnapshotIntervalMs;
536
+ return { state: 'skipped' };
537
+ }
538
+ view.lastSnapshotAt = now();
539
+ view.nextSnapshotAllowedAt = null;
540
+ const gen = generation;
541
+ const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
542
+ headers: identityHeaders(peer, store),
543
+ });
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();
549
+ const bytes = Buffer.byteLength(text, 'utf8');
550
+ if (bytes > 3 * 1024 * 1024) {
551
+ view.stale = true; view.lastError = 'snapshot-oversize';
552
+ return { state: 'failed' };
553
+ }
554
+ const snap = JSON.parse(text);
555
+ // Il contratto della decisione decide l'ESITO dello snapshot; lo schema
556
+ // della vista decide l'APPLICAZIONE. Due giudizi, una sola funzione di
557
+ // regole.
558
+ const decision = profile === 'view'
559
+ ? null
560
+ : validateSnapshot(ownerId, snap, bytes, contentTypeOf(r), 'decision');
561
+ const forView = validateSnapshot(ownerId, snap, bytes, contentTypeOf(r), 'view');
562
+ if (profile !== 'view' && !decision.ok) {
563
+ // La causa resta in lastError: è ciò che l'operatore (e il cooldown
564
+ // riarmato) mostrano finché l'owner non torna sanitario.
565
+ view.stale = true; view.lastError = decision.reason;
566
+ return { state: 'failed' };
567
+ }
568
+ if (!forView.ok) {
569
+ if (profile !== 'view') {
570
+ // Autorevole per la chiusura, non applicabile alla vista: la vista
571
+ // resta com'è (stale, con la causa sua), la decisione va avanti.
572
+ view.stale = true; view.lastError = forView.reason;
573
+ return { state: 'applied', asks: snap.asks, appliedToView: false };
355
574
  }
575
+ view.stale = true; view.lastError = forView.reason;
576
+ return { state: 'failed' };
356
577
  }
357
- // A snapshot over the server budget is a floor, not a whole: resync.
358
- if (snap.resyncRequired) { view.stale = true; return false; }
359
578
  applySnapshot(ownerId, snap, gen);
360
- return true;
579
+ return { state: 'applied', asks: snap.asks, appliedToView: true };
361
580
  }
362
581
 
363
582
  // One reconciliation round across every enabled peer. Never throws.
@@ -370,18 +589,68 @@ function createEventFeedClient(opts = {}) {
370
589
  await Promise.all(peers.map(async (peer) => {
371
590
  const ownerId = peer.nodeId;
372
591
  if (running.has(ownerId)) return; // one loop per pair
592
+ // Il guard vale per TUTTO il round, non solo per lo stream: due poll
593
+ // sovrapposti (tick + backoff, o macchina carica) facevano due snapshot
594
+ // 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: () => {} };
598
+ running.set(ownerId, slot);
373
599
  const view = viewFor(ownerId);
374
600
  try {
375
- if (view.unsupported || (view.consecutiveResets || 0) >= MAX_CONSECUTIVE_RESETS
376
- || (view.ingressBlockedUntil || 0) > Date.now()) return;
377
- if (!view.capabilityChecked) {
378
- const ok = await checkCapability(peer, ownerId);
379
- if (!ok) return;
601
+ // L'invariante PRIMA del consumo del cooldown: se la finestra non è
602
+ // trascorsa il tick non fa nulla — non consuma la scadenza del
603
+ // cooldown di recupero (il tentativo vero avverrà a finestra passata,
604
+ // col suo flag di recupero intatto) e non tocca il conto dei reset.
605
+ if (!view.cursor && view.lastSnapshotAt !== undefined
606
+ && now() - view.lastSnapshotAt < minSnapshotIntervalMs) {
607
+ view.stale = true;
608
+ return;
380
609
  }
381
- if (!view.cursor) {
382
- const ok = await snapshotOnce(peer, ownerId, store);
383
- if (!ok) return;
610
+ // Il cooldown scaduto paga UN tentativo e azzera lo streak: se il
611
+ // tentativo fallisce, una nuova sequenza di reset deve poter
612
+ // ri-esaurire e ri-armare il cooldown al gradino successivo. Finché il
613
+ // cooldown è in piedi nessuna richiesta parte (il tappo resta in piedi).
614
+ let recovery = false;
615
+ if (view.resyncBlockedUntil) {
616
+ if (now() < view.resyncBlockedUntil) return;
617
+ view.resyncBlockedUntil = null;
618
+ view.consecutiveResets = 0;
619
+ recovery = true;
620
+ log(`event-feed-client: ${ownerId.slice(0, 8)}… resync cooldown expired: one fresh snapshot attempt`);
384
621
  }
622
+ if (view.unsupported || (view.ingressBlockedUntil || 0) > Date.now()) return;
623
+ // La fase di ACQUISIZIONE del tentativo (capability + snapshot) ha UN
624
+ // SOLO punto di uscita per gli esiti non riusciti: throw, timeout,
625
+ // abort e 'failed' convergono tutti lì — non un ramo per tipo di
626
+ // errore, così il prossimo modo di fallire non sfugge. Un fallimento
627
+ // in questa fase con il cooldown scaduto RIARMA il gradino successivo
628
+ // (un tentativo per scadenza, la causa resta in lastError); l'errore
629
+ // lanciato si risolleva per il catch esterno (stale, lastError,
630
+ // backoff). Lo stream resta fuori dalla fase: un suo errore dopo uno
631
+ // snapshot riuscito è regime di trasporto (backoff), non di resync.
632
+ // 'skipped' non è un fallimento: l'invariante della finestra ha solo
633
+ // posticipato la richiesta, la scala non si tocca.
634
+ let attemptError = null;
635
+ let snapState = null; // null = tentativo non fatto (cursor già valido)
636
+ try {
637
+ if (!view.capabilityChecked) {
638
+ const capOk = await checkCapability(peer, ownerId);
639
+ if (!capOk) return; // senza la capability il gate blocca: nessun tentativo fatto
640
+ }
641
+ if (!view.cursor) {
642
+ snapState = await snapshotOnce(peer, ownerId, store); // profilo view
643
+ }
644
+ } catch (e) {
645
+ attemptError = e;
646
+ }
647
+ if (attemptError || (snapState && snapState.state === 'failed')) {
648
+ if (recovery) armResyncCooldown(view, attemptError ? 'recovery attempt failed' : 'recovery snapshot failed');
649
+ }
650
+ if (attemptError) throw attemptError;
651
+ // Un tentativo fallito o posticipato ferma il round; NESSUN tentativo
652
+ // (cursor già valido) va dritto allo stream.
653
+ if (snapState && snapState.state !== 'applied') return;
385
654
  await streamOnce(peer, ownerId, store);
386
655
  } catch (e) {
387
656
  view.stale = true;
@@ -393,6 +662,10 @@ function createEventFeedClient(opts = {}) {
393
662
  const delay = backoffMs(peer);
394
663
  log(`event-feed-client: ${ownerId.slice(0, 8)}… retry in ${delay} ms (${view.lastError})`);
395
664
  setTimeout(() => { void poll(); }, delay);
665
+ } 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.
668
+ if (running.get(ownerId) === slot) running.delete(ownerId);
396
669
  }
397
670
  }));
398
671
  }
@@ -421,6 +694,12 @@ function createEventFeedClient(opts = {}) {
421
694
  return {
422
695
  views: [...views.entries()].map(([ownerId, v]) => ({
423
696
  ownerId, cursor: v.cursor, viewEpoch: v.viewEpoch, stale: v.stale,
697
+ // Diagnostica (doctor / feed-state): dove la view è e quanto le
698
+ // manca — il cooldown residuo si legge con un confronto col clock.
699
+ resyncBlockedUntil: v.resyncBlockedUntil || null,
700
+ resyncExhaustions: v.resyncExhaustions || 0,
701
+ // L'attesa dell'invariante finestra, separata dal cooldown.
702
+ nextSnapshotAllowedAt: v.lastSnapshotAt === undefined ? null : v.lastSnapshotAt + minSnapshotIntervalMs,
424
703
  // Both sides of the merge matter to the UI: the answer/dismiss gate
425
704
  // (askReplyAccess) and the ingress/error state of the view.
426
705
  askReplyAccess: v.askReplyAccess === true,
@@ -432,7 +711,100 @@ function createEventFeedClient(opts = {}) {
432
711
  };
433
712
  }
434
713
 
435
- return { start, stop, poll, state, reemit, viewFor };
714
+ // A dismiss this node has CONFIRMED with the owner (the relay returned 2xx, or
715
+ // the owner said so in a frame): only then does the view forget the ask, and
716
+ // only then is it tombstoned. An uncertain or failed dismiss hides nothing.
717
+ function dismissConfirmed(ownerId, askId) {
718
+ const view = dropAskFromView(ownerId, askId);
719
+ noteAskClosed(ownerId, askId, view.viewEpoch);
720
+ return true;
721
+ }
722
+
723
+ // Esito TIPIZZATO: {status:'ok', asks} | {status:'skipped'|'pending'|'error',
724
+ // reason, retryAt}. SOLO 'ok' è autorevole (snapshot fresco, col contratto
725
+ // della decisione soddisfatto, acquisito in questa chiamata): uno skip, un
726
+ // pending, una finestra chiusa, un errore o un timeout non decidono MAI la
727
+ // chiusura di un alias.
728
+ const doorWindowAt = new Map(); // ownerId -> ultimo tentativo (owner non sottoscritti)
729
+ const doorInFlight = new Set(); // ownerId -> tentativo in volo (owner sottoscritti)
730
+ const DOOR_TIMEOUT_MS = 3000;
731
+ function doorTimeoutPromise() {
732
+ return new Promise((resolve) => {
733
+ const t = setTimeout(() => resolve(null), DOOR_TIMEOUT_MS);
734
+ if (typeof t.unref === 'function') t.unref();
735
+ });
736
+ }
737
+ async function ownerSnapshotAsks(ownerId) {
738
+ const store = opts.loadStore();
739
+ if (!store) return { status: 'error', reason: 'no-store' };
740
+ const id = String(ownerId);
741
+ const peer = (store.nodes || []).find((n) => n && n.nodeId === id && n.token && n.localPort);
742
+ if (!peer) return { status: 'error', reason: 'peer-unknown' };
743
+ if (peer.eventsReceive === true) {
744
+ if (doorInFlight.has(id)) {
745
+ const view = views.get(id);
746
+ const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
747
+ return { status: 'pending', reason: 'attempt-in-flight', retryAt };
748
+ }
749
+ doorInFlight.add(id);
750
+ let state = null;
751
+ try {
752
+ // Profilo DECISION: la porta decide la chiusura degli alias, legge
753
+ // l'elenco che la decisione usa — e solo quello. La vista, nello
754
+ // stesso snapshotOnce, applica il suo schema completo o resta ferma.
755
+ state = await Promise.race([snapshotOnce(peer, id, store, { profile: 'decision' }), doorTimeoutPromise()]);
756
+ } catch (_) {
757
+ return { status: 'error', reason: 'transport' };
758
+ } finally {
759
+ doorInFlight.delete(id);
760
+ }
761
+ const view = views.get(id);
762
+ const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
763
+ if (state === null) return { status: 'pending', reason: 'door-timeout', retryAt };
764
+ if (state.state === 'skipped') return { status: 'skipped', reason: 'window', retryAt };
765
+ if (state.state === 'applied') {
766
+ // L'elenco autorevole è quello dello snapshot: anche quando la vista
767
+ // non ha potuto applicarlo (appliedToView false) la decisione ha il
768
+ // suo contratto soddisfatto.
769
+ return Array.isArray(state.asks)
770
+ ? { status: 'ok', asks: state.asks }
771
+ : { status: 'error', reason: 'applied-without-view' };
772
+ }
773
+ return { status: 'error', reason: 'snapshot-failed' };
774
+ }
775
+ const last = doorWindowAt.get(id);
776
+ if (last !== undefined && now() - last < minSnapshotIntervalMs) {
777
+ return { status: 'skipped', reason: 'window', retryAt: last + minSnapshotIntervalMs };
778
+ }
779
+ doorWindowAt.set(id, now());
780
+ // UN AbortController per fetch E corpo: un owner che manda gli header e
781
+ // non chiude il corpo non può appendere chi ha chiesto lo snapshot.
782
+ const ctrl = new AbortController();
783
+ const timer = setTimeout(() => ctrl.abort(), DOOR_TIMEOUT_MS);
784
+ if (typeof timer.unref === 'function') timer.unref();
785
+ try {
786
+ const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
787
+ headers: identityHeaders(peer, store), signal: ctrl.signal,
788
+ });
789
+ if (!r.ok) return { status: 'error', reason: `http-${r.status}` };
790
+ const text = await r.text();
791
+ const bytes = Buffer.byteLength(text, 'utf8');
792
+ let snap;
793
+ try { snap = JSON.parse(text); } catch (_) { return { status: 'error', reason: 'shape' }; }
794
+ // STESSO validatore della via sottoscritta, allo STESSO profilo
795
+ // decisionale: ownerId atteso, content-type, resyncRequired e i cap
796
+ // dell'elenco decidono qui esattamente come decidono là.
797
+ const verdict = validateSnapshot(id, snap, bytes, contentTypeOf(r), 'decision');
798
+ if (!verdict.ok) return { status: 'error', reason: verdict.reason };
799
+ return { status: 'ok', asks: snap.asks };
800
+ } catch (_) {
801
+ return { status: 'error', reason: 'transport' };
802
+ } finally {
803
+ clearTimeout(timer);
804
+ }
805
+ }
806
+
807
+ return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks };
436
808
  }
437
809
 
438
810
  module.exports = { createEventFeedClient };