Skip to content

Commit ca10d51

Browse files
committed
fix(telegram): guard heartbeat pulses and export the interval constant
- add an in-flight guard so a beat is skipped while the previous pulse is still unsettled, preventing pulse pile-up when a pulse is parked in the API retry layer; the flag is released in a finally so a rejected pulse can't wedge it - export TYPING_HEARTBEAT_MS and assert the <=5s auto-clear invariant against the production constant instead of a test-local mirror
1 parent 26dc28e commit ca10d51

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

packages/core/src/channels/telegram.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const GENERIC_TRANSPORT_APOLOGY = "Sorry, something went wrong. Please try again
5050
// How often to re-emit Telegram's native "typing" indicator while a turn is in
5151
// flight. Telegram auto-clears the indicator ~5s after each sendChatAction, so we
5252
// refresh faster than that to keep it continuous without flicker.
53-
const TYPING_HEARTBEAT_MS = 4_000;
53+
export const TYPING_HEARTBEAT_MS = 4_000;
5454

5555
function drainBounded(drain: () => Promise<void>, timeoutMs: number): Promise<void> {
5656
return Promise.race([
@@ -70,8 +70,21 @@ export function startTypingHeartbeat(
7070
intervalMs: number,
7171
onError: (err: unknown) => void,
7272
): () => void {
73+
// Skip a beat while the previous pulse is still unsettled: under a Telegram
74+
// flood a pulse can be parked inside the API retry layer, and firing a fresh
75+
// one every interval regardless would pile parked pulses up and roughly double
76+
// request volume against an already-throttled API. The flag is cleared in a
77+
// finally so a rejected pulse cannot wedge the guard permanently.
78+
let inFlight = false;
7379
const beat = () => {
74-
void Promise.resolve().then(pulse).catch(onError);
80+
if (inFlight) return;
81+
inFlight = true;
82+
void Promise.resolve()
83+
.then(pulse)
84+
.catch(onError)
85+
.finally(() => {
86+
inFlight = false;
87+
});
7588
};
7689
beat();
7790
const timer = setInterval(beat, intervalMs);

packages/core/tests/telegram-dispatch.test.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { APICallError } from "@ai-sdk/provider";
66
import { cyclingBinary } from "./helpers/cycling-binary-fixture.js";
7-
import { startTypingHeartbeat } from "../src/channels/telegram.js";
7+
import { startTypingHeartbeat, TYPING_HEARTBEAT_MS } from "../src/channels/telegram.js";
88

99
let dataDir: string;
1010

@@ -450,10 +450,6 @@ describe("retry transformer / classified errors / delivery split", () => {
450450
});
451451

452452
describe("typing-indicator heartbeat", () => {
453-
// Mirrors the un-exported TYPING_HEARTBEAT_MS in telegram.ts. The <= 5s
454-
// assertion below keeps the heartbeat under Telegram's ~5s typing auto-clear window.
455-
const HEARTBEAT_MS = 4_000;
456-
457453
describe("in-flight pulses (integration)", () => {
458454
beforeEach(() => {
459455
vi.useFakeTimers();
@@ -482,10 +478,10 @@ describe("typing-indicator heartbeat", () => {
482478
expect(afterImmediate).toBeGreaterThanOrEqual(1);
483479

484480
// One interval → at least one more pulse; and a second interval → another.
485-
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS);
486-
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS);
481+
await vi.advanceTimersByTimeAsync(TYPING_HEARTBEAT_MS);
482+
await vi.advanceTimersByTimeAsync(TYPING_HEARTBEAT_MS);
487483
expect(ctx.replyWithChatAction.mock.calls.length).toBeGreaterThanOrEqual(afterImmediate + 2);
488-
expect(HEARTBEAT_MS).toBeLessThanOrEqual(5_000);
484+
expect(TYPING_HEARTBEAT_MS).toBeLessThanOrEqual(5_000);
489485

490486
// Let the turn finish so no fake interval leaks past the test.
491487
resolveChat("done");
@@ -504,14 +500,14 @@ describe("typing-indicator heartbeat", () => {
504500
const ctx = makeCtx({ message: { text: "how's my form?" } });
505501

506502
await getMessageText(bot)(ctx);
507-
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS);
503+
await vi.advanceTimersByTimeAsync(TYPING_HEARTBEAT_MS);
508504

509505
resolveChat("the answer body");
510506
await drainPending();
511507
expect(someReply(ctx, "the answer body")).toBe(true);
512508

513509
const frozen = ctx.replyWithChatAction.mock.calls.length;
514-
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 5);
510+
await vi.advanceTimersByTimeAsync(TYPING_HEARTBEAT_MS * 5);
515511
expect(ctx.replyWithChatAction.mock.calls.length).toBe(frozen);
516512
});
517513

@@ -526,7 +522,7 @@ describe("typing-indicator heartbeat", () => {
526522
expect(someReply(ctx, "Rate limited — please try again in ~30 seconds.")).toBe(true);
527523

528524
const frozen = ctx.replyWithChatAction.mock.calls.length;
529-
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 5);
525+
await vi.advanceTimersByTimeAsync(TYPING_HEARTBEAT_MS * 5);
530526
expect(ctx.replyWithChatAction.mock.calls.length).toBe(frozen);
531527
});
532528

@@ -572,7 +568,7 @@ describe("typing-indicator heartbeat", () => {
572568
vi.stubGlobal("setInterval", setIntervalSpy);
573569
vi.stubGlobal("clearInterval", clearIntervalSpy);
574570
try {
575-
const stop = startTypingHeartbeat(async () => undefined, HEARTBEAT_MS, () => {});
571+
const stop = startTypingHeartbeat(async () => undefined, TYPING_HEARTBEAT_MS, () => {});
576572
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
577573
expect(unref).toHaveBeenCalledTimes(1);
578574
expect(clearIntervalSpy).not.toHaveBeenCalled();
@@ -584,6 +580,39 @@ describe("typing-indicator heartbeat", () => {
584580
vi.unstubAllGlobals();
585581
}
586582
});
583+
584+
it("skips beats while the previous pulse is still unsettled, then resumes once it settles", async () => {
585+
vi.useFakeTimers();
586+
try {
587+
let settle!: () => void;
588+
const pulse = vi.fn(
589+
() =>
590+
new Promise<void>((resolve) => {
591+
settle = resolve;
592+
}),
593+
);
594+
const stop = startTypingHeartbeat(pulse, 10_000, () => {});
595+
596+
// The immediate beat fires exactly one pulse, which stays pending.
597+
await vi.advanceTimersByTimeAsync(0);
598+
expect(pulse).toHaveBeenCalledTimes(1);
599+
600+
// Several intervals elapse while that pulse is parked — every tick is
601+
// skipped by the in-flight guard, so still only the one pulse.
602+
await vi.advanceTimersByTimeAsync(10_000 * 3);
603+
expect(pulse).toHaveBeenCalledTimes(1);
604+
605+
// Settle the parked pulse; the guard releases and the next tick beats.
606+
settle();
607+
await vi.advanceTimersByTimeAsync(0);
608+
await vi.advanceTimersByTimeAsync(10_000);
609+
expect(pulse).toHaveBeenCalledTimes(2);
610+
611+
stop();
612+
} finally {
613+
vi.useRealTimers();
614+
}
615+
});
587616
});
588617
});
589618

0 commit comments

Comments
 (0)