refactor: unify monitor abort lifecycle handling

This commit is contained in:
Peter Steinberger
2026-02-26 04:36:00 +01:00
parent 02c731826a
commit e915b4c64a
13 changed files with 319 additions and 103 deletions

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = vi.hoisted(() => ({
createLineBotMock: vi.fn(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn(),
})),
registerPluginHttpRouteMock: vi.fn(),
unregisterHttpMock: vi.fn(),
}));
vi.mock("./bot.js", () => ({
createLineBot: createLineBotMock,
}));
vi.mock("../plugins/http-path.js", () => ({
normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback,
}));
vi.mock("../plugins/http-registry.js", () => ({
registerPluginHttpRoute: registerPluginHttpRouteMock,
}));
vi.mock("./webhook-node.js", () => ({
createLineNodeWebhookHandler: vi.fn(() => vi.fn()),
}));
describe("monitorLineProvider lifecycle", () => {
beforeEach(() => {
createLineBotMock.mockClear();
unregisterHttpMock.mockClear();
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
});
it("waits for abort before resolving", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
let resolved = false;
const task = monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
abortSignal: abort.signal,
}).then((monitor) => {
resolved = true;
return monitor;
});
await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1));
expect(resolved).toBe(false);
abort.abort();
await task;
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("stops immediately when signal is already aborted", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
abort.abort();
await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
abortSignal: abort.signal,
});
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("returns immediately without abort signal and stop is idempotent", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
expect(unregisterHttpMock).not.toHaveBeenCalled();
monitor.stop();
monitor.stop();
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,6 +4,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import type { OpenClawConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import { waitForAbortSignal } from "../infra/abort-signal.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -296,7 +297,12 @@ export async function monitorLineProvider(
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
// Handle abort signal
let stopped = false;
const stopHandler = () => {
if (stopped) {
return;
}
stopped = true;
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
recordChannelRuntimeState({
@@ -309,7 +315,12 @@ export async function monitorLineProvider(
});
};
abortSignal?.addEventListener("abort", stopHandler);
if (abortSignal?.aborted) {
stopHandler();
} else if (abortSignal) {
abortSignal.addEventListener("abort", stopHandler, { once: true });
await waitForAbortSignal(abortSignal);
}
return {
account: bot.account,