fix(telegram): bound offset confirmation timeout (#50368) (thanks @boticlaw)

This commit is contained in:
Peter Steinberger
2026-04-20 23:58:34 +01:00
parent 45ffb6cc25
commit 77a6187a70
4 changed files with 54 additions and 3 deletions

View File

@@ -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

View File

@@ -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"]);
});

View File

@@ -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);

View File

@@ -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<typeof createTelegramBot>;
type TelegramApiAbortSignal = Parameters<TelegramBot["api"]["getUpdates"]>[1];
const waitForGracefulStop = async (stop: () => Promise<void>) => {
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -43,7 +47,8 @@ const waitForGracefulStop = async (stop: () => Promise<void>) => {
}
};
type TelegramBot = ReturnType<typeof createTelegramBot>;
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.