























@@ -226,6 +226,159 @@ describe("gateway restart deferral preflight", () => {
226226});
227227});
228228229+describe("gateway channel hot reload handlers", () => {
230+function createChannelReloadPlan(channels: ChannelKind[]): GatewayReloadPlan {
231+return {
232+changedPaths: channels.map((channel) => `channels.${channel}.enabled`),
233+restartGateway: false,
234+restartReasons: [],
235+hotReasons: ["channels"],
236+reloadHooks: false,
237+restartGmailWatcher: false,
238+restartCron: false,
239+restartHeartbeat: false,
240+restartHealthMonitor: false,
241+reloadPlugins: false,
242+restartChannels: new Set(channels),
243+disposeMcpRuntimes: false,
244+noopPaths: [],
245+};
246+}
247+248+async function withChannelReloadsEnabled(run: () => Promise<void>) {
249+const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
250+const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
251+delete process.env.OPENCLAW_SKIP_CHANNELS;
252+delete process.env.OPENCLAW_SKIP_PROVIDERS;
253+try {
254+await run();
255+} finally {
256+if (previousSkipChannels === undefined) {
257+delete process.env.OPENCLAW_SKIP_CHANNELS;
258+} else {
259+process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels;
260+}
261+if (previousSkipProviders === undefined) {
262+delete process.env.OPENCLAW_SKIP_PROVIDERS;
263+} else {
264+process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders;
265+}
266+}
267+}
268+269+it("continues restarting later channels after a hot-reload stop failure", async () => {
270+const events: string[] = [];
271+const setState = vi.fn();
272+const logChannels = { info: vi.fn(), error: vi.fn() };
273+const stopChannel = vi.fn(async (channel: ChannelKind) => {
274+events.push(`stop:${channel}`);
275+if (channel === "telegram") {
276+throw new Error("stop failed");
277+}
278+});
279+const startChannel = vi.fn(async (channel: ChannelKind) => {
280+events.push(`start:${channel}`);
281+});
282+const { applyHotReload } = createGatewayReloadHandlers({
283+deps: {} as never,
284+broadcast: vi.fn(),
285+getState: () => ({
286+hooksConfig: {} as never,
287+hookClientIpConfig: {} as never,
288+heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
289+cronState: {
290+cron: { start: vi.fn(async () => {}), stop: vi.fn() },
291+storePath: "/tmp/cron.json",
292+cronEnabled: false,
293+} as never,
294+channelHealthMonitor: null,
295+}),
296+ setState,
297+ startChannel,
298+ stopChannel,
299+reloadPlugins: vi.fn(
300+async (): Promise<GatewayPluginReloadResult> => ({
301+restartChannels: new Set(),
302+activeChannels: new Set(),
303+}),
304+),
305+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
306+ logChannels,
307+logCron: { error: vi.fn() },
308+logReload: { info: vi.fn(), warn: vi.fn() },
309+createHealthMonitor: () => null,
310+});
311+312+await withChannelReloadsEnabled(async () => {
313+await expect(
314+applyHotReload(createChannelReloadPlan(["telegram", "discord"]), {}),
315+).rejects.toThrow("failed to restart channels during hot reload: telegram");
316+});
317+318+expect(events).toEqual(["stop:telegram", "stop:discord", "start:discord"]);
319+expect(logChannels.error).toHaveBeenCalledWith(
320+"failed to restart telegram channel during hot reload: stop failed",
321+);
322+expect(setState).not.toHaveBeenCalled();
323+});
324+325+it("continues restarting later channels after a hot-reload start failure", async () => {
326+const events: string[] = [];
327+const setState = vi.fn();
328+const logChannels = { info: vi.fn(), error: vi.fn() };
329+const stopChannel = vi.fn(async (channel: ChannelKind) => {
330+events.push(`stop:${channel}`);
331+});
332+const startChannel = vi.fn(async (channel: ChannelKind) => {
333+events.push(`start:${channel}`);
334+if (channel === "telegram") {
335+throw new Error("start failed");
336+}
337+});
338+const { applyHotReload } = createGatewayReloadHandlers({
339+deps: {} as never,
340+broadcast: vi.fn(),
341+getState: () => ({
342+hooksConfig: {} as never,
343+hookClientIpConfig: {} as never,
344+heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
345+cronState: {
346+cron: { start: vi.fn(async () => {}), stop: vi.fn() },
347+storePath: "/tmp/cron.json",
348+cronEnabled: false,
349+} as never,
350+channelHealthMonitor: null,
351+}),
352+ setState,
353+ startChannel,
354+ stopChannel,
355+reloadPlugins: vi.fn(
356+async (): Promise<GatewayPluginReloadResult> => ({
357+restartChannels: new Set(),
358+activeChannels: new Set(),
359+}),
360+),
361+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
362+ logChannels,
363+logCron: { error: vi.fn() },
364+logReload: { info: vi.fn(), warn: vi.fn() },
365+createHealthMonitor: () => null,
366+});
367+368+await withChannelReloadsEnabled(async () => {
369+await expect(
370+applyHotReload(createChannelReloadPlan(["telegram", "discord"]), {}),
371+).rejects.toThrow("failed to restart channels during hot reload: telegram");
372+});
373+374+expect(events).toEqual(["stop:telegram", "start:telegram", "stop:discord", "start:discord"]);
375+expect(logChannels.error).toHaveBeenCalledWith(
376+"failed to restart telegram channel during hot reload: start failed",
377+);
378+expect(setState).not.toHaveBeenCalled();
379+});
380+});
381+229382describe("gateway Gmail hot reload handlers", () => {
230383function createGmailReloadPlan(): GatewayReloadPlan {
231384return {
@@ -586,6 +739,115 @@ describe("gateway Gmail hot reload handlers", () => {
586739});
587740588741describe("gateway plugin hot reload handlers", () => {
742+it("rolls back stopped channels when plugin pre-replace stop fails", async () => {
743+const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
744+const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
745+delete process.env.OPENCLAW_SKIP_CHANNELS;
746+delete process.env.OPENCLAW_SKIP_PROVIDERS;
747+const cron = { start: vi.fn(async () => {}), stop: vi.fn() };
748+const heartbeatRunner = {
749+stop: vi.fn(),
750+updateConfig: vi.fn(),
751+};
752+const setState = vi.fn();
753+const logChannels = { info: vi.fn(), error: vi.fn() };
754+const events: string[] = [];
755+const startChannel = vi.fn(async (channel: ChannelKind) => {
756+events.push(`start:${channel}`);
757+});
758+const stopChannel = vi.fn(async (channel: ChannelKind) => {
759+events.push(`stop:${channel}`);
760+if (channel === "discord") {
761+throw new Error("stop failed");
762+}
763+});
764+const reloadPlugins = vi.fn(
765+async (params: {
766+beforeReplace: (channels: ReadonlySet<ChannelKind>) => Promise<void>;
767+}): Promise<GatewayPluginReloadResult> => {
768+events.push("reload:start");
769+await params.beforeReplace(new Set(["telegram", "discord"]));
770+events.push("registry:replace");
771+return {
772+restartChannels: new Set(),
773+activeChannels: new Set(),
774+};
775+},
776+);
777+const { applyHotReload } = createGatewayReloadHandlers({
778+deps: {} as never,
779+broadcast: vi.fn(),
780+getState: () => ({
781+hooksConfig: {} as never,
782+hookClientIpConfig: {} as never,
783+heartbeatRunner: heartbeatRunner as never,
784+cronState: { cron, storePath: "/tmp/cron.json", cronEnabled: false } as never,
785+channelHealthMonitor: null,
786+}),
787+ setState,
788+ startChannel,
789+ stopChannel,
790+ reloadPlugins,
791+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
792+ logChannels,
793+logCron: { error: vi.fn() },
794+logReload: { info: vi.fn(), warn: vi.fn() },
795+createHealthMonitor: () => null,
796+});
797+798+try {
799+await expect(
800+applyHotReload(
801+{
802+changedPaths: ["plugins.enabled"],
803+restartGateway: false,
804+restartReasons: [],
805+hotReasons: ["plugins.enabled"],
806+reloadHooks: false,
807+restartGmailWatcher: false,
808+restartCron: false,
809+restartHeartbeat: false,
810+restartHealthMonitor: false,
811+reloadPlugins: true,
812+restartChannels: new Set(),
813+disposeMcpRuntimes: false,
814+noopPaths: [],
815+},
816+{
817+plugins: {
818+enabled: false,
819+},
820+},
821+),
822+).rejects.toThrow("failed to stop channels before plugin reload: discord");
823+} finally {
824+if (previousSkipChannels === undefined) {
825+delete process.env.OPENCLAW_SKIP_CHANNELS;
826+} else {
827+process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels;
828+}
829+if (previousSkipProviders === undefined) {
830+delete process.env.OPENCLAW_SKIP_PROVIDERS;
831+} else {
832+process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders;
833+}
834+}
835+836+expect(events).toEqual([
837+"reload:start",
838+"stop:telegram",
839+"stop:discord",
840+"start:telegram",
841+"start:discord",
842+]);
843+expect(logChannels.error).toHaveBeenCalledWith(
844+"failed to stop discord channel before plugin reload: stop failed",
845+);
846+expect(startChannel).toHaveBeenCalledWith("telegram");
847+expect(startChannel).toHaveBeenCalledWith("discord");
848+expect(setState).not.toHaveBeenCalled();
849+});
850+589851it("stops removed channel plugins from broad activation before swapping plugin runtime", async () => {
590852const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
591853const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。