@inetafrica/open-claudia 3.1.27 → 3.2.0

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.
@@ -623,6 +623,270 @@ async function testOutagePauseDoesNotFeedTheWedgeExitClock() {
623
623
  }
624
624
  }
625
625
 
626
+ // The fleet reboot loop, reproduced. Everything above stubs the poller; this
627
+ // harness MODELS it, because the bug lived in the interaction the stubs elided:
628
+ // _stopPollingClean destroys the agent, and in production that kills the
629
+ // in-flight getUpdates and re-enters _onPollingHiccup ~1ms later. A stub whose
630
+ // destroy() is a no-op can never show that, which is exactly why v3.1.27's unit
631
+ // test passed against a bug it did not fix (it drove _enterOutagePause, a path
632
+ // the dominant failure never takes). Here destroy() raises the error for real,
633
+ // a poll takes the full 30s hold to complete, and a virtual clock runs the
634
+ // burst past the 10-minute wedge-exit budget.
635
+ function makeVirtualPoller(clock) {
636
+ const state = { polling: null, pendingPoll: null, onSelfAbort: null, startCount: 0 };
637
+ const bot = {
638
+ _polling: null,
639
+ async stopPolling() {
640
+ // A plain stop only sets the abort flag — the HTTP request is still open
641
+ // on the wire. It is _agent.destroy() below that actually kills it, which
642
+ // is precisely why the resulting error looks like a network fault.
643
+ if (state.polling) state.polling._abort = true;
644
+ },
645
+ async startPolling() {
646
+ state.startCount += 1;
647
+ state.polling = { _abort: false, _lastUpdate: 0 }; // fresh object ⇒ _lastUpdate resets to 0
648
+ bot._polling = state.polling;
649
+ const mine = state.polling;
650
+ // A healthy getUpdates completes only after the full 30s long-poll hold.
651
+ state.pendingPoll = clock.at(clock.now() + 30000, () => {
652
+ state.pendingPoll = null;
653
+ if (!mine._abort) mine._lastUpdate = clock.now();
654
+ });
655
+ },
656
+ };
657
+ const agent = {
658
+ destroy() {
659
+ // The real behaviour: tearing the socket down surfaces as a polling error.
660
+ // No re-dial here — a plain stop set _abort, so the lib's loop does not
661
+ // reschedule; the heal's own startPolling is what brings polling back.
662
+ if (state.pendingPoll) {
663
+ clock.cancel(state.pendingPoll);
664
+ state.pendingPoll = null;
665
+ if (state.onSelfAbort) state.onSelfAbort("EFATAL: Error: socket hang up");
666
+ }
667
+ },
668
+ };
669
+ // A poll that dies on its own (a genuine network fault, _abort still false).
670
+ // node-telegram-bot-api re-dials within ~300ms, which is why a request is
671
+ // ALWAYS back in flight by the time a heal runs — and therefore why the heal
672
+ // always had something to destroy. The live capture shows exactly this:
673
+ // reqPending was true at every heal:start.
674
+ state.crash = (msg) => {
675
+ if (state.pendingPoll) { clock.cancel(state.pendingPoll); state.pendingPoll = null; }
676
+ if (state.onSelfAbort) state.onSelfAbort(msg);
677
+ const mine = state.polling;
678
+ state.pendingPoll = clock.at(clock.now() + 300, () => {
679
+ state.pendingPoll = clock.at(clock.now() + 30000, () => {
680
+ state.pendingPoll = null;
681
+ if (mine && !mine._abort) mine._lastUpdate = clock.now();
682
+ });
683
+ });
684
+ };
685
+ return { bot, agent, state };
686
+ }
687
+
688
+ function makeClock(start) {
689
+ let now = start;
690
+ let seq = 0;
691
+ const timers = new Map();
692
+ return {
693
+ now: () => now,
694
+ at(when, fn) { const id = ++seq; timers.set(id, { when, fn }); return id; },
695
+ cancel(id) { timers.delete(id); },
696
+ pending: () => timers.size,
697
+ async advanceTo(target) {
698
+ for (;;) {
699
+ let pick = null;
700
+ for (const [id, t] of timers) {
701
+ if (t.when > target) continue;
702
+ if (!pick || t.when < pick.t.when || (t.when === pick.t.when && id < pick.id)) pick = { id, t };
703
+ }
704
+ if (!pick) break;
705
+ timers.delete(pick.id);
706
+ now = Math.max(now, pick.t.when);
707
+ await pick.t.fn();
708
+ }
709
+ now = target;
710
+ },
711
+ };
712
+ }
713
+
714
+ async function testOneNetworkBlipDoesNotSpiralIntoTheWedgeExitLoop() {
715
+ const clock = makeClock(1_700_000_000_000);
716
+ const { bot, agent, state } = makeVirtualPoller(clock);
717
+
718
+ const adapter = Object.create(TelegramAdapter.prototype);
719
+ adapter._healTimer = null;
720
+ adapter._healthyTimer = null;
721
+ adapter._healthyBaseline = 0;
722
+ adapter._healthyReArmMsg = "";
723
+ adapter._selfAbortUntil = 0;
724
+ adapter._wedgedSince = 0;
725
+ adapter._healBackoff = 0;
726
+ adapter._deferredExitLogAt = 0;
727
+ adapter._lastHiccupLog = 0;
728
+ adapter._pausedForOutage = false;
729
+ adapter._outageProbeTimer = null;
730
+ adapter._conflictSince = 0;
731
+ adapter._conflictTimer = null;
732
+ adapter._pollStartedAt = 0;
733
+ adapter._anyTurnActive = () => false;
734
+ adapter._probeTelegramReachable = async () => true; // Telegram was reachable throughout the real burst
735
+ adapter._agent = agent;
736
+ adapter.bot = bot;
737
+ state.onSelfAbort = (msg) => adapter._onPollingHiccup(msg);
738
+
739
+ let exited = false;
740
+ const realExit = process.exit;
741
+ const realNow = Date.now;
742
+ const realSetTimeout = global.setTimeout;
743
+ const realClearTimeout = global.clearTimeout;
744
+ Date.now = () => clock.now();
745
+ process.exit = () => { exited = true; throw new Error("__exit__"); };
746
+ global.setTimeout = (fn, ms) => {
747
+ if (ms === 500 || ms === 5000) { fn(); return { inline: true }; } // stop-clean internals
748
+ return clock.at(clock.now() + ms, fn);
749
+ };
750
+ global.clearTimeout = (id) => { if (id && !id.inline) clock.cancel(id); };
751
+
752
+ try {
753
+ await bot.startPolling(); // healthy, mid long-poll hold
754
+ await clock.advanceTo(clock.now() + 1000);
755
+ // ONE genuine socket reset — the only external event in the captured burst.
756
+ state.crash("EFATAL: Error: read ECONNRESET");
757
+ assert.ok(adapter._wedgedSince > 0, "a real error must open the wedge window");
758
+
759
+ // Run well past the 10-minute wedge-exit budget.
760
+ for (let i = 0; i < 40 && !exited; i += 1) {
761
+ try { await clock.advanceTo(clock.now() + 30000); }
762
+ catch (e) { if (e.message !== "__exit__") throw e; }
763
+ }
764
+
765
+ assert.strictEqual(exited, false,
766
+ "a single ECONNRESET must not end in process.exit — the heal's own teardown was " +
767
+ "re-triggering the heal, so the wedge clock never cleared and every burst ran to " +
768
+ "the 10-min backstop (155 self-exits observed locally, 210+ per pod in the fleet)");
769
+ assert.strictEqual(adapter._wedgedSince, 0,
770
+ "once a getUpdates completes again the wedge must clear");
771
+ assert.strictEqual(adapter._healBackoff, 0, "recovery must reset the heal ladder");
772
+ assert.ok(bot._polling._lastUpdate > 0,
773
+ "the poll loop must actually complete a getUpdates — pre-fix _lastUpdate stayed 0 forever " +
774
+ "because each heal aborted the 30s long-poll after 15s");
775
+ assert.ok(state.startCount <= 4,
776
+ `recovery must take a couple of restarts, not a storm (took ${state.startCount})`);
777
+ } finally {
778
+ Date.now = realNow;
779
+ process.exit = realExit;
780
+ global.setTimeout = realSetTimeout;
781
+ global.clearTimeout = realClearTimeout;
782
+ }
783
+ }
784
+
785
+ // Fix 1 in isolation: the "socket hang up" our own _agent.destroy() raises must
786
+ // not schedule a heal. Pre-fix this was the engine of the loop.
787
+ async function testOurOwnTeardownIsNotCountedAsAFault() {
788
+ const clock = makeClock(1_700_000_000_000);
789
+ const { bot, agent, state } = makeVirtualPoller(clock);
790
+
791
+ const adapter = Object.create(TelegramAdapter.prototype);
792
+ Object.assign(adapter, {
793
+ _healTimer: null, _healthyTimer: null, _healthyBaseline: 0, _healthyReArmMsg: "",
794
+ _selfAbortUntil: 0, _wedgedSince: 0, _healBackoff: 0, _deferredExitLogAt: 0,
795
+ _lastHiccupLog: 0, _pausedForOutage: false, _outageProbeTimer: null,
796
+ _conflictSince: 0, _conflictTimer: null, _pollStartedAt: 0,
797
+ _anyTurnActive: () => false, _probeTelegramReachable: async () => true,
798
+ _agent: agent, bot,
799
+ });
800
+
801
+ const scheduled = [];
802
+ const realNow = Date.now;
803
+ const realSetTimeout = global.setTimeout;
804
+ const realClearTimeout = global.clearTimeout;
805
+ Date.now = () => clock.now();
806
+ global.setTimeout = (fn, ms) => {
807
+ if (ms === 500 || ms === 5000) { fn(); return { inline: true }; }
808
+ scheduled.push(ms);
809
+ return clock.at(clock.now() + ms, fn);
810
+ };
811
+ global.clearTimeout = (id) => { if (id && !id.inline) clock.cancel(id); };
812
+
813
+ let selfAbortSeen = 0;
814
+ state.onSelfAbort = (msg) => { selfAbortSeen += 1; adapter._onPollingHiccup(msg); };
815
+
816
+ try {
817
+ await bot.startPolling();
818
+ await clock.advanceTo(clock.now() + 1000);
819
+ adapter._wedgedSince = clock.now(); // already in a wedge, as when a heal runs
820
+ scheduled.length = 0;
821
+ await adapter._heal(); // tears the socket down → raises our own error
822
+
823
+ assert.strictEqual(selfAbortSeen, 1, "the harness must actually raise the teardown error");
824
+ assert.deepStrictEqual(scheduled, [35000],
825
+ "the only timer a heal may arm is its 35s healthy check — pre-fix the teardown error " +
826
+ "also queued another heal, which tore down the socket again, forever");
827
+ assert.ok(clock.now() < adapter._selfAbortUntil,
828
+ "the self-abort window must be open across the restart");
829
+ } finally {
830
+ Date.now = realNow;
831
+ global.setTimeout = realSetTimeout;
832
+ global.clearTimeout = realClearTimeout;
833
+ }
834
+ }
835
+
836
+ // Fix 2: the heal ladder must out-wait one getUpdates. A cap below the poll
837
+ // hold makes recovery unobservable — _lastUpdate can never advance.
838
+ function testHealLadderOutWaitsTheLongPoll() {
839
+ const adapter = Object.create(TelegramAdapter.prototype);
840
+ adapter._healBackoff = 0;
841
+ const delays = [];
842
+ const realSetTimeout = global.setTimeout;
843
+ global.setTimeout = (fn, ms) => { delays.push(ms); return { ms }; };
844
+ try {
845
+ for (let i = 0; i < 10; i += 1) { adapter._healTimer = null; adapter._scheduleHeal(); }
846
+ } finally {
847
+ global.setTimeout = realSetTimeout;
848
+ }
849
+ const settled = delays[delays.length - 1];
850
+ assert.ok(settled > 40000,
851
+ `the settled heal interval (${settled}ms) must exceed the 40s request timeout that bounds ` +
852
+ "one getUpdates; the old 15s cap was HALF the 30s long-poll hold, so every attempt was " +
853
+ "aborted before it could complete and recovery was structurally impossible");
854
+ assert.ok(delays[0] <= 1000, "early rungs must still retry fast while polling is down");
855
+ }
856
+
857
+ // Fix 3: re-arming must not push the deadline back, or a heal cadence shorter
858
+ // than the check window starves the only code that clears the wedge.
859
+ function testHealthyCheckCannotBeStarvedByRepeatedReArms() {
860
+ const adapter = Object.create(TelegramAdapter.prototype);
861
+ adapter._healthyTimer = null;
862
+ adapter._healthyBaseline = 0;
863
+ adapter._healthyReArmMsg = "";
864
+
865
+ let armCount = 0;
866
+ let cleared = 0;
867
+ const realSetTimeout = global.setTimeout;
868
+ const realClearTimeout = global.clearTimeout;
869
+ global.setTimeout = (fn, ms) => { armCount += 1; return { ms, fn }; };
870
+ global.clearTimeout = () => { cleared += 1; };
871
+ try {
872
+ adapter._armHealthyCheck(1000, "first");
873
+ adapter._armHealthyCheck(2000, "second");
874
+ adapter._armHealthyCheck(3000, "third");
875
+ } finally {
876
+ global.setTimeout = realSetTimeout;
877
+ global.clearTimeout = realClearTimeout;
878
+ }
879
+
880
+ assert.strictEqual(armCount, 1,
881
+ "the pending healthy check must keep its original deadline — re-arming it on every heal " +
882
+ "is why zero healthy checks fired in a 200s+ live capture");
883
+ assert.strictEqual(cleared, 0, "a pending healthy check must never be cancelled by a re-arm");
884
+ assert.strictEqual(adapter._healthyBaseline, 3000,
885
+ "the baseline must track the NEWEST restart, so a poll completed before it cannot be " +
886
+ "mistaken for proof that the latest restart worked");
887
+ assert.strictEqual(adapter._healthyReArmMsg, "third", "the re-arm reason must track the newest restart too");
888
+ }
889
+
626
890
  (async () => {
627
891
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
628
892
  await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
@@ -636,6 +900,10 @@ async function testOutagePauseDoesNotFeedTheWedgeExitClock() {
636
900
  await testHealthySignalReArmsWhenNoPollCompleted();
637
901
  await testHealthySignalClearsWedgeOnceAPollCompletes();
638
902
  await testOutagePauseDoesNotFeedTheWedgeExitClock();
903
+ await testOneNetworkBlipDoesNotSpiralIntoTheWedgeExitLoop();
904
+ await testOurOwnTeardownIsNotCountedAsAFault();
905
+ testHealLadderOutWaitsTheLongPoll();
906
+ testHealthyCheckCannotBeStarvedByRepeatedReArms();
639
907
  console.log("telegram poll recovery OK");
640
908
  })().catch((err) => {
641
909
  console.error(err.stack || err);