From 2220a58ff795e5feec0270c21b3f4620570e8bc8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:03:28 +0000 Subject: [PATCH] fix: abort telegram getupdates on shutdown (#23950) (thanks @Gkinthecodeland) --- CHANGELOG.md | 1 + src/telegram/bot.create-telegram-bot.test.ts | 21 ------------ src/telegram/bot.fetch-abort.test.ts | 34 ++++++++++++++++++++ src/telegram/bot.ts | 3 +- 4 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 src/telegram/bot.fetch-abort.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b4cbe4a93..685b1ea16d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. - Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468. - Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat. +- Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland. - Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. ## 2026.3.7 diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 07edc4f5432..378c1eb1065 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -75,27 +75,6 @@ describe("createTelegramBot", () => { globalThis.fetch = originalFetch; } }); - it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { - const originalFetch = globalThis.fetch; - const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => init?.signal); - const shutdown = new AbortController(); - globalThis.fetch = fetchSpy as unknown as typeof fetch; - try { - createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal }); - const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) - ?.client?.fetch as ((input: RequestInfo | URL, init?: RequestInit) => Promise); - expect(clientFetch).toBeTypeOf("function"); - - const observedSignal = (await clientFetch("https://example.test")) as AbortSignal; - expect(observedSignal).toBeInstanceOf(AbortSignal); - expect(observedSignal.aborted).toBe(false); - - shutdown.abort(new Error("shutdown")); - expect(observedSignal.aborted).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } - }); it("applies global and per-account timeoutSeconds", () => { loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.fetch-abort.test.ts b/src/telegram/bot.fetch-abort.test.ts new file mode 100644 index 00000000000..471654686f7 --- /dev/null +++ b/src/telegram/bot.fetch-abort.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js"; +import { createTelegramBot } from "./bot.js"; + +describe("createTelegramBot fetch abort", () => { + it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { + const originalFetch = globalThis.fetch; + const shutdown = new AbortController(); + const fetchSpy = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => resolve(signal), { once: true }); + }), + ); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + botCtorSpy.mockClear(); + createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal }); + const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise; + expect(clientFetch).toBeTypeOf("function"); + + const observedSignalPromise = clientFetch("https://example.test"); + shutdown.abort(new Error("shutdown")); + const observedSignal = (await observedSignalPromise) as AbortSignal; + + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 24decd85670..8bfa0b8ac0c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -110,8 +110,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // (especially long-polling getUpdates) aborts immediately on shutdown. Without this, // the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting // its own poll triggers a 409 Conflict from Telegram. - let finalFetch: NonNullable | undefined = - shouldProvideFetch && fetchImpl ? fetchForClient : undefined; + let finalFetch = shouldProvideFetch && fetchImpl ? fetchForClient : undefined; if (opts.fetchAbortSignal) { const baseFetch = finalFetch ?? (globalThis.fetch as unknown as NonNullable);