diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e86ad71394..0973119584f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo. - Google Chat/Lifecycle: keep Google Chat `startAccount` pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy. - Temp dirs/Linux umask: force `0700` permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so `umask 0002` installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky. +- Nextcloud Talk/Lifecycle: keep `startAccount` pending until abort and stop the webhook monitor on shutdown, preventing `EADDRINUSE` restart loops when the gateway manages account lifecycle. (#27897) - Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011. - Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427) - Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin. diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts new file mode 100644 index 00000000000..68f8490efb9 --- /dev/null +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -0,0 +1,115 @@ +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; + +const hoisted = vi.hoisted(() => ({ + monitorNextcloudTalkProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, + }; +}); + +import { nextcloudTalkPlugin } from "./channel.js"; + +function createStartAccountCtx(params: { + account: ResolvedNextcloudTalkAccount; + abortSignal: AbortSignal; +}): ChannelGatewayContext { + const snapshot: ChannelAccountSnapshot = { + accountId: params.account.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.account.accountId, + account: params.account, + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + abortSignal: params.abortSignal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: (next) => { + Object.assign(snapshot, next); + }, + }; +} + +function buildAccount(): ResolvedNextcloudTalkAccount { + return { + accountId: "default", + enabled: true, + baseUrl: "https://nextcloud.example.com", + secret: "secret", + secretSource: "config", + config: { + baseUrl: "https://nextcloud.example.com", + botSecret: "secret", + webhookPath: "/nextcloud-talk-webhook", + webhookPort: 8788, + }, + }; +} + +describe("nextcloudTalkPlugin gateway.startAccount", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + const abort = new AbortController(); + + const task = nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountCtx({ + account: buildAccount(), + abortSignal: abort.signal, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + let settled = false; + void task.then(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled).toBe(false); + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).not.toHaveBeenCalled(); + + abort.abort(); + await task; + + expect(stop).toHaveBeenCalledOnce(); + }); + + it("stops immediately when startAccount receives an already-aborted signal", async () => { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + const abort = new AbortController(); + abort.abort(); + + await nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountCtx({ + account: buildAccount(), + abortSignal: abort.signal, + }), + ); + + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index c0cfa8e44be..e49f057878c 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -12,6 +12,7 @@ import { type OpenClawConfig, type ChannelSetupInput, } from "openclaw/plugin-sdk"; +import { waitForAbortSignal } from "../../../src/infra/abort-signal.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -332,7 +333,9 @@ export const nextcloudTalkPlugin: ChannelPlugin = statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), }); - return { stop }; + // Keep webhook channels pending for the account lifecycle. + await waitForAbortSignal(ctx.abortSignal); + stop(); }, logoutAccount: async ({ accountId, cfg }) => { const nextCfg = { ...cfg } as OpenClawConfig; diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 3fb3da3e75b..2de886864b7 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -276,12 +276,25 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe }); }; + let stopped = false; const stop = () => { - server.close(); + if (stopped) { + return; + } + stopped = true; + try { + server.close(); + } catch { + // ignore close races while shutting down + } }; if (abortSignal) { - abortSignal.addEventListener("abort", stop, { once: true }); + if (abortSignal.aborted) { + stop(); + } else { + abortSignal.addEventListener("abort", stop, { once: true }); + } } return { server, start, stop }; @@ -384,7 +397,14 @@ export async function monitorNextcloudTalkProvider( abortSignal: opts.abortSignal, }); + if (opts.abortSignal?.aborted) { + return { stop }; + } await start(); + if (opts.abortSignal?.aborted) { + stop(); + return { stop }; + } const publicUrl = account.config.webhookPublicUrl ??