diff --git a/extensions/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts index 9b9ca471d330..397f1241dbaf 100644 --- a/extensions/telegram/src/webhook.test.ts +++ b/extensions/telegram/src/webhook.test.ts @@ -920,6 +920,28 @@ describe("startTelegramWebhook", () => { } }); + it("continues webhook shutdown after bot stop fails", async () => { + const runtimeError = vi.fn(); + const setStatus = vi.fn(); + stopSpy.mockRejectedValueOnce(new Error("bot stop failed")); + + const started = await startTelegramWebhook({ + token: TELEGRAM_TOKEN, + port: 0, + secret: TELEGRAM_SECRET, + path: TELEGRAM_WEBHOOK_PATH, + spoolDir: requireWebhookSpoolDir(), + setStatus, + runtime: { log: vi.fn(), error: runtimeError, exit: vi.fn() }, + }); + + await expect(started.stop()).resolves.toBeUndefined(); + + expect(transportCloseSpies[0]).toHaveBeenCalledOnce(); + expect(setStatus).toHaveBeenLastCalledWith({ mode: "webhook", connected: false }); + expectMockMessageContains(runtimeError, "telegram webhook bot stop failed"); + }); + it("marks delivery accepted only after the durable enqueue commits", async () => { let releaseEnqueue: (() => void) | undefined; let markEnqueueStarted: (() => void) | undefined; diff --git a/extensions/telegram/src/webhook.ts b/extensions/telegram/src/webhook.ts index 3224a2df1650..7bb9dd417c47 100644 --- a/extensions/telegram/src/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -529,24 +529,41 @@ export async function startTelegramWebhook(opts: { }); let webhookAdvertised = false; + const runShutdownPhase = async ( + label: string, + run: () => void | Promise, + ): Promise => { + try { + await run(); + } catch (err) { + runtime.error?.(`telegram webhook ${label} failed: ${formatErrorMessage(err)}`); + } + }; const shutdown = async () => { if (shutDown) { return; } - botAbortController.abort(); shutDown = true; + botAbortController.abort(); shutdownAbortController.abort(); - const ingressStopTask = webhookIngressMonitor?.stop(); + // Every fallible phase is isolated so one failed release cannot skip the + // remaining resources or reject the fire-and-forget abort hook. + const ingressMonitor = webhookIngressMonitor; webhookIngressMonitor = undefined; - server.close(); - await bot.stop(); + const ingressStopTask = ingressMonitor + ? runShutdownPhase("ingress stop", () => ingressMonitor.stop()) + : undefined; + await runShutdownPhase("server close", () => { + server.close(); + }); + await runShutdownPhase("bot stop", () => bot.stop()); // The webhook owns this transport because it resolved and injected it into // createTelegramBot; close once so abort/startup-failure paths cannot leak sockets. - await closeTransportOnce(); - await waitForWebhookIngressStop(ingressStopTask); - status.noteWebhookStop(); + await runShutdownPhase("transport close", closeTransportOnce); + await runShutdownPhase("ingress drain", () => waitForWebhookIngressStop(ingressStopTask)); + await runShutdownPhase("status update", () => status.noteWebhookStop()); if (diagnosticsEnabled) { - stopDiagnosticHeartbeat(); + await runShutdownPhase("diagnostics stop", () => stopDiagnosticHeartbeat()); } }; if (opts.abortSignal?.aborted) {