From b67bcd93cc9ea94e6798d0809e3b7d6e98d71c42 Mon Sep 17 00:00:00 2001 From: Eden <146086744+edenfunf@users.noreply.github.com> Date: Fri, 15 May 2026 20:47:10 +0800 Subject: [PATCH] fix(twitch): keep account monitor alive until abort (#81853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Keep Twitch startAccount alive until abort via runStoppablePassiveMonitor. - Add lifecycle regression coverage and env-gated live Twitch IRC proof. - Add changelog credit for #60071 / #81853. Verification: - pnpm test extensions/twitch/src/plugin.lifecycle.test.ts extensions/twitch/src/plugin.test.ts extensions/twitch/src/twitch-client.test.ts src/gateway/server-channels.test.ts - pnpm exec oxfmt --check --threads=1 extensions/twitch/src/plugin.ts extensions/twitch/src/plugin.lifecycle.test.ts extensions/twitch/src/plugin.live.test.ts CHANGELOG.md - pnpm test:live -- extensions/twitch/src/plugin.live.test.ts (skipped without Twitch live credentials) - codex-review --mode branch --parallel-tests targeted Twitch/gateway tests - GitHub checks on aea52056c6d7b487a86c6f3b9c6b69fe86e44413 green Fixes #60071. Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com> --- CHANGELOG.md | 1 + .../twitch/src/plugin.lifecycle.test.ts | 86 +++++++++++++ extensions/twitch/src/plugin.live.test.ts | 120 ++++++++++++++++++ extensions/twitch/src/plugin.ts | 27 ++-- 4 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 extensions/twitch/src/plugin.lifecycle.test.ts create mode 100644 extensions/twitch/src/plugin.live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f13fb2a143..52bca4e5389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf. - Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc. - Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev. - System events: keep owner downgrades in structured metadata while rendering queued prompt text as plain `System:` lines, preserving least-privilege wakeups without prompt-visible trust labels. (#82067) diff --git a/extensions/twitch/src/plugin.lifecycle.test.ts b/extensions/twitch/src/plugin.lifecycle.test.ts new file mode 100644 index 00000000000..b8e51f35e1e --- /dev/null +++ b/extensions/twitch/src/plugin.lifecycle.test.ts @@ -0,0 +1,86 @@ +import { + createStartAccountContext, + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, + waitForStartedMocks, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TwitchAccountConfig } from "./types.js"; + +const hoisted = vi.hoisted(() => ({ + monitorTwitchProvider: vi.fn(), +})); + +vi.mock("./monitor.js", () => ({ + monitorTwitchProvider: hoisted.monitorTwitchProvider, +})); + +const { twitchPlugin } = await import("./plugin.js"); + +type TwitchStartAccount = NonNullable["startAccount"]>; + +function requireStartAccount(): TwitchStartAccount { + const startAccount = twitchPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("Expected Twitch gateway startAccount"); + } + return startAccount; +} + +function buildAccount(): TwitchAccountConfig & { accountId: string } { + return { + accountId: "default", + username: "testbot", + accessToken: "oauth:test-token", + clientId: "test-client-id", + channel: "#testchannel", + enabled: true, + }; +} + +function mockStartedMonitor() { + const stop = vi.fn(); + hoisted.monitorTwitchProvider.mockResolvedValue({ stop }); + return stop; +} + +function startTwitchAccount(abortSignal?: AbortSignal) { + return requireStartAccount()( + createStartAccountContext({ + account: buildAccount(), + abortSignal, + }), + ); +} + +describe("twitch startAccount lifecycle", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = mockStartedMonitor(); + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: requireStartAccount(), + account: buildAccount(), + }); + await expectStopPendingUntilAbort({ + waitForStarted: waitForStartedMocks(hoisted.monitorTwitchProvider), + isSettled, + abort, + task, + stop, + }); + }); + + it("stops immediately when startAccount receives an already-aborted signal", async () => { + const stop = mockStartedMonitor(); + const abort = new AbortController(); + abort.abort(); + + await startTwitchAccount(abort.signal); + + expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/twitch/src/plugin.live.test.ts b/extensions/twitch/src/plugin.live.test.ts new file mode 100644 index 00000000000..6b3b2c74247 --- /dev/null +++ b/extensions/twitch/src/plugin.live.test.ts @@ -0,0 +1,120 @@ +/** + * Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle + * pattern used by the Twitch gateway. + * + * This test connects to irc.chat.twitch.tv using the same twurple stack the + * Twitch plugin uses, then drives that connection through the helper this PR + * wires into twitchPlugin.gateway.startAccount. It asserts the post-fix + * invariant — startAccount-shaped task stays pending after a successful + * connection and only resolves when the abort signal fires — using real + * network rather than mocks. + * + * Skipped by default. Enable with: + * TWITCH_LIVE_TEST=1 + * TWITCH_USERNAME= + * TWITCH_ACCESS_TOKEN= + * TWITCH_CLIENT_ID= + * TWITCH_CHANNEL= + */ + +import { StaticAuthProvider } from "@twurple/auth"; +import { ChatClient } from "@twurple/chat"; +import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; +import { describe, expect, it } from "vitest"; + +const LIVE = process.env.TWITCH_LIVE_TEST === "1"; +const HAS_CREDS = Boolean( + process.env.TWITCH_USERNAME && + process.env.TWITCH_ACCESS_TOKEN && + process.env.TWITCH_CLIENT_ID && + process.env.TWITCH_CHANNEL, +); + +const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip; + +maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => { + it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => { + const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, ""); + const clientId = process.env.TWITCH_CLIENT_ID!; + const channel = process.env.TWITCH_CHANNEL!; + const username = process.env.TWITCH_USERNAME!; + + const start = Date.now(); + const log = (msg: string) => { + console.log(`[T+${Date.now() - start}ms] ${msg}`); + }; + + log(`username=${username} channel=#${channel}`); + + const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [ + "chat:read", + "chat:edit", + ]); + + const abort = new AbortController(); + let connectedAt: number | null = null; + let settled = false; + let stopCalled = false; + + const task = runStoppablePassiveMonitor({ + abortSignal: abort.signal, + start: async () => { + const chat = new ChatClient({ + authProvider, + channels: [channel], + authIntents: ["chat"], + }); + + chat.onConnect(() => { + connectedAt = Date.now() - start; + log(`Connected to Twitch as ${username}`); + }); + chat.onJoin((joinedChannel: string, joinedUser: string) => { + log(`Joined #${joinedChannel} as ${joinedUser}`); + }); + chat.onDisconnect((manually: boolean, reason?: Error) => { + log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`); + }); + + chat.connect(); + + return { + stop: () => { + stopCalled = true; + log(`stop() invoked`); + chat.quit(); + }, + }; + }, + }) + .then(() => { + settled = true; + log(`task RESOLVED`); + }) + .catch((err: unknown) => { + settled = true; + log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`); + throw err; + }); + + // Wait long enough that the original bug would have manifested. + // The reported time-to-restart in #60071 is ~2ms after connect. + const WATCH_MS = 15_000; + await new Promise((resolve) => setTimeout(resolve, WATCH_MS)); + + expect(connectedAt, "expected onConnect within the watch window").not.toBeNull(); + expect(settled, "task must not have settled before abort").toBe(false); + log( + `--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`, + ); + + abort.abort(); + log(`abort() called`); + + await task; + + expect(settled).toBe(true); + expect(stopCalled, "stop hook must run on abort").toBe(true); + log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`); + }, 60_000); +}); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 26a3b931d7a..39c9aca21d6 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -13,7 +13,10 @@ import { createPairingPrefixStripper, } from "openclaw/plugin-sdk/channel-pairing"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { + buildPassiveProbedChannelStatusSummary, + runStoppablePassiveMonitor, +} from "openclaw/plugin-sdk/extension-shared"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -180,14 +183,22 @@ export const twitchPlugin: ChannelPlugin = ctx.log?.info(`Starting Twitch connection for ${account.username}`); - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorTwitchProvider } = await import("./monitor.js"); - await monitorTwitchProvider({ - account, - accountId, - config: ctx.cfg, - runtime: ctx.runtime, + // Keep startAccount pending until abort fires; otherwise the channel + // supervisor reads the settled task as `channel exited without an + // error` and triggers a restart loop. See #60071. + await runStoppablePassiveMonitor({ abortSignal: ctx.abortSignal, + start: async () => { + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorTwitchProvider } = await import("./monitor.js"); + return monitorTwitchProvider({ + account, + accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, }); }, stopAccount: async (ctx): Promise => {