mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 08:31:55 +00:00
refactor: harden msteams lifecycle and attachment flows
This commit is contained in:
66
src/plugin-sdk/channel-lifecycle.test.ts
Normal file
66
src/plugin-sdk/channel-lifecycle.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js";
|
||||
|
||||
type FakeServer = EventEmitter & {
|
||||
close: (callback?: () => void) => void;
|
||||
};
|
||||
|
||||
function createFakeServer(): FakeServer {
|
||||
const server = new EventEmitter() as FakeServer;
|
||||
server.close = (callback) => {
|
||||
queueMicrotask(() => {
|
||||
server.emit("close");
|
||||
callback?.();
|
||||
});
|
||||
};
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("plugin-sdk channel lifecycle helpers", () => {
|
||||
it("resolves waitUntilAbort when signal aborts", async () => {
|
||||
const abort = new AbortController();
|
||||
const task = waitUntilAbort(abort.signal);
|
||||
|
||||
const early = await Promise.race([
|
||||
task.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
|
||||
]);
|
||||
expect(early).toBe("pending");
|
||||
|
||||
abort.abort();
|
||||
await expect(task).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps server task pending until close, then resolves", async () => {
|
||||
const server = createFakeServer();
|
||||
const task = keepHttpServerTaskAlive({ server });
|
||||
|
||||
const early = await Promise.race([
|
||||
task.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
|
||||
]);
|
||||
expect(early).toBe("pending");
|
||||
|
||||
server.close();
|
||||
await expect(task).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("triggers abort hook once and resolves after close", async () => {
|
||||
const server = createFakeServer();
|
||||
const abort = new AbortController();
|
||||
const onAbort = vi.fn(async () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
const task = keepHttpServerTaskAlive({
|
||||
server,
|
||||
abortSignal: abort.signal,
|
||||
onAbort,
|
||||
});
|
||||
|
||||
abort.abort();
|
||||
await expect(task).resolves.toBeUndefined();
|
||||
expect(onAbort).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
66
src/plugin-sdk/channel-lifecycle.ts
Normal file
66
src/plugin-sdk/channel-lifecycle.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type CloseAwareServer = {
|
||||
once: (event: "close", listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a promise that resolves when the signal is aborted.
|
||||
*
|
||||
* If no signal is provided, the promise stays pending forever.
|
||||
*/
|
||||
export function waitUntilAbort(signal?: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a channel/provider task pending until the HTTP server closes.
|
||||
*
|
||||
* When an abort signal is provided, `onAbort` is invoked once and should
|
||||
* trigger server shutdown. The returned promise resolves only after `close`.
|
||||
*/
|
||||
export async function keepHttpServerTaskAlive(params: {
|
||||
server: CloseAwareServer;
|
||||
abortSignal?: AbortSignal;
|
||||
onAbort?: () => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
const { server, abortSignal, onAbort } = params;
|
||||
let abortTask: Promise<void> = Promise.resolve();
|
||||
let abortTriggered = false;
|
||||
|
||||
const triggerAbort = () => {
|
||||
if (abortTriggered) {
|
||||
return;
|
||||
}
|
||||
abortTriggered = true;
|
||||
abortTask = Promise.resolve(onAbort?.()).then(() => undefined);
|
||||
};
|
||||
|
||||
const onAbortSignal = () => {
|
||||
triggerAbort();
|
||||
};
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
triggerAbort();
|
||||
} else {
|
||||
abortSignal.addEventListener("abort", onAbortSignal, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.once("close", () => resolve());
|
||||
});
|
||||
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener("abort", onAbortSignal);
|
||||
}
|
||||
await abortTask;
|
||||
}
|
||||
@@ -149,6 +149,7 @@ export {
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS,
|
||||
} from "./webhook-request-guards.js";
|
||||
export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js";
|
||||
export { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js";
|
||||
export type { AgentMediaPayload } from "./agent-media-payload.js";
|
||||
export { buildAgentMediaPayload } from "./agent-media-payload.js";
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user