From 77a6187a70348c60d1c4ae3a13b71b76313a5e66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 23:58:34 +0100 Subject: [PATCH] fix(telegram): bound offset confirmation timeout (#50368) (thanks @boticlaw) --- CHANGELOG.md | 2 + extensions/telegram/src/monitor.test.ts | 5 ++- .../telegram/src/polling-session.test.ts | 41 +++++++++++++++++++ extensions/telegram/src/polling-session.ts | 9 +++- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b373b55305e..d18cc4e52c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/polling: bound the persisted-offset confirmation `getUpdates` probe with a client-side timeout so a zombie socket cannot hang polling recovery before the runner watchdog starts. (#50368) Thanks @boticlaw. - Plugins/memory: preserve the active memory capability when read-only snapshot plugin loads run, so status and provider discovery paths no longer wipe memory public artifacts. (#69219) Thanks @zeroaltitude. - Plugins: keep only the highest-precedence manifest when distinct discovered plugins share an id, so lower-precedence global or workspace duplicates no longer load beside bundled or config-selected plugins. (#41626) Thanks @Tortes. - fix(security): block MINIMAX_API_HOST workspace env injection and remove env-driven URL routing [AI-assisted]. (#67300) Thanks @pgondhi987. @@ -27,6 +28,7 @@ Docs: https://docs.openclaw.ai - Gateway/websocket broadcasts: require `operator.read` (or higher) for chat, agent, and tool-result event frames so pairing-scoped and node-role sessions no longer passively receive session chat content, and scope-gate unknown broadcast events by default. Plugin-defined `plugin.*` broadcasts are scoped to operator.write/admin, and status/transport events (`heartbeat`, `presence`, `tick`, etc.) remain unrestricted. Per-client sequence numbers preserve per-connection monotonicity. (#69373) Thanks @eleqtrizit. - Agents/compaction: always reload embedded Pi resources through an explicit loader and reapply reserve-token overrides so runs without extension factories no longer silently lose compaction settings before session start. (#67146) Thanks @ly85206559. - Memory-core/dreaming: normalize sweep timestamps and reuse hashed narrative session keys for fallback cleanup so Dreaming narrative sub-sessions stop leaking. (#67023) Thanks @chiyouYCH. + ## 2026.4.20 ### Changes diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 9e25ca7c4f2..55f202fb26e 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -743,7 +743,10 @@ describe("monitorTelegramProvider (grammY)", () => { persistedOffset: 549076203, }); - expect(api.getUpdates).toHaveBeenCalledWith({ offset: 549076204, limit: 1, timeout: 0 }); + expect(api.getUpdates).toHaveBeenCalledWith( + { offset: 549076204, limit: 1, timeout: 0 }, + expect.any(AbortSignal), + ); expect(order).toEqual(["deleteWebhook", "getUpdates", "run"]); }); diff --git a/extensions/telegram/src/polling-session.test.ts b/extensions/telegram/src/polling-session.test.ts index 01cec86c0f5..eabcd6e52c0 100644 --- a/extensions/telegram/src/polling-session.test.ts +++ b/extensions/telegram/src/polling-session.test.ts @@ -278,6 +278,47 @@ describe("TelegramPollingSession", () => { expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); }); + it("bounds the persisted offset confirmation getUpdates call", async () => { + const abort = new AbortController(); + const timeoutSignal = new AbortController().signal; + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutSignal); + const bot = makeBot(); + createTelegramBotMock.mockReturnValueOnce(bot); + runMock.mockReturnValueOnce({ + task: async () => { + abort.abort(); + }, + stop: vi.fn(async () => undefined), + isRunning: () => false, + }); + + const session = new TelegramPollingSession({ + token: "tok", + config: {}, + accountId: "default", + runtime: undefined, + proxyFetch: undefined, + abortSignal: abort.signal, + runnerOptions: {}, + getLastUpdateId: () => 41, + persistUpdateId: async () => undefined, + log: () => undefined, + telegramTransport: undefined, + }); + + try { + await session.runUntilAbort(); + + expect(timeoutSpy).toHaveBeenCalledWith(10_000); + expect(bot.api.getUpdates).toHaveBeenCalledWith( + { offset: 42, limit: 1, timeout: 0 }, + timeoutSignal, + ); + } finally { + timeoutSpy.mockRestore(); + } + }); + it("forces a restart when polling stalls without getUpdates activity", async () => { const abort = new AbortController(); const botStop = vi.fn(async () => undefined); diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index e1a39173c30..894d11cf8c9 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -25,6 +25,10 @@ const TELEGRAM_POLL_RESTART_POLICY = { const POLL_STALL_THRESHOLD_MS = 90_000; const POLL_WATCHDOG_INTERVAL_MS = 30_000; const POLL_STOP_GRACE_MS = 15_000; +const CONFIRM_PERSISTED_OFFSET_TIMEOUT_MS = 10_000; + +type TelegramBot = ReturnType; +type TelegramApiAbortSignal = Parameters[1]; const waitForGracefulStop = async (stop: () => Promise) => { let timer: ReturnType | undefined; @@ -43,7 +47,8 @@ const waitForGracefulStop = async (stop: () => Promise) => { } }; -type TelegramBot = ReturnType; +const telegramApiTimeoutSignal = (timeoutMs: number): TelegramApiAbortSignal => + AbortSignal.timeout(timeoutMs) as unknown as TelegramApiAbortSignal; type TelegramPollingSessionOpts = { token: string; @@ -212,7 +217,7 @@ export class TelegramPollingSession { try { await bot.api.getUpdates( { offset: lastUpdateId + 1, limit: 1, timeout: 0 }, - { signal: AbortSignal.timeout(10000) }, + telegramApiTimeoutSignal(CONFIRM_PERSISTED_OFFSET_TIMEOUT_MS), ); } catch { // Non-fatal: runner middleware still skips duplicates via shouldSkipUpdate.