





















@@ -1,11 +1,31 @@
11import { afterEach, describe, expect, it, vi } from "vitest";
2-import type { ChannelKind } from "./config-reload-plan.js";
2+import type { ConfigWriteNotification } from "../config/config.js";
3+import type { OpenClawConfig } from "../config/types.openclaw.js";
4+import type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js";
35import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
4-import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
6+import {
7+createGatewayReloadHandlers,
8+startManagedGatewayConfigReloader,
9+} from "./server-reload-handlers.js";
10+11+type GmailWatcherRestartParams = {
12+cfg: OpenClawConfig;
13+log: {
14+info: (msg: string) => void;
15+warn: (msg: string) => void;
16+error: (msg: string) => void;
17+};
18+onSkipped?: () => void;
19+isCancelled?: () => boolean;
20+signal?: AbortSignal;
21+};
22+23+type StartGmailWatcherWithLogs = (params: GmailWatcherRestartParams) => Promise<void>;
24+type StopGmailWatcher = () => Promise<void>;
525626const hoisted = vi.hoisted(() => ({
7-startGmailWatcherWithLogs: vi.fn(async () => {}),
8-stopGmailWatcher: vi.fn(async () => {}),
27+startGmailWatcherWithLogs: vi.fn<StartGmailWatcherWithLogs>(async () => {}),
28+stopGmailWatcher: vi.fn<StopGmailWatcher>(async () => {}),
929activeTaskCount: { value: 0 },
1030activeTaskBlockers: [] as Array<{
1131taskId: string;
@@ -172,6 +192,31 @@ describe("gateway restart deferral preflight", () => {
172192});
173193174194describe("gateway Gmail hot reload handlers", () => {
195+function createGmailReloadPlan(): GatewayReloadPlan {
196+return {
197+changedPaths: ["hooks.gmail.account"],
198+restartGateway: false,
199+restartReasons: [],
200+hotReasons: ["hooks.gmail.account"],
201+reloadHooks: false,
202+restartGmailWatcher: true,
203+restartCron: false,
204+restartHeartbeat: false,
205+restartHealthMonitor: false,
206+reloadPlugins: false,
207+restartChannels: new Set<ChannelKind>(),
208+disposeMcpRuntimes: false,
209+noopPaths: [],
210+};
211+}
212+213+function createGmailConfig(account: string): OpenClawConfig {
214+return {
215+gateway: { reload: { debounceMs: 0 } },
216+hooks: { enabled: true, gmail: { account } },
217+};
218+}
219+175220it("stops queued post-ready sidecars before restarting Gmail watcher", async () => {
176221const stopPostReadySidecars = vi.fn();
177222const { applyHotReload } = createGatewayReloadHandlers({
@@ -232,6 +277,277 @@ describe("gateway Gmail hot reload handlers", () => {
232277expect.objectContaining({ cfg: nextConfig }),
233278);
234279});
280+281+it("passes a cancellable signal to Gmail watcher restarts", async () => {
282+const abortController = new AbortController();
283+const clearGmailRestartAbortController = vi.fn();
284+const { applyHotReload } = createGatewayReloadHandlers({
285+deps: {} as never,
286+broadcast: vi.fn(),
287+getState: () => ({
288+hooksConfig: {} as never,
289+hookClientIpConfig: {} as never,
290+heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
291+cronState: {
292+cron: { start: vi.fn(async () => {}), stop: vi.fn() },
293+storePath: "/tmp/cron.json",
294+cronEnabled: false,
295+} as never,
296+channelHealthMonitor: null,
297+}),
298+setState: vi.fn(),
299+startChannel: vi.fn(async () => {}),
300+stopChannel: vi.fn(async () => {}),
301+reloadPlugins: vi.fn(
302+async (): Promise<GatewayPluginReloadResult> => ({
303+restartChannels: new Set(),
304+activeChannels: new Set(),
305+}),
306+),
307+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
308+logChannels: { info: vi.fn(), error: vi.fn() },
309+logCron: { error: vi.fn() },
310+logReload: { info: vi.fn(), warn: vi.fn() },
311+createHealthMonitor: () => null,
312+createGmailRestartAbortController: () => abortController,
313+ clearGmailRestartAbortController,
314+});
315+const nextConfig = createGmailConfig("next@example.com");
316+317+await applyHotReload(createGmailReloadPlan(), nextConfig);
318+319+const [restartParams] = hoisted.startGmailWatcherWithLogs.mock.calls[0] ?? [];
320+expect(restartParams).toMatchObject({ cfg: nextConfig });
321+expect(restartParams?.signal).toBe(abortController.signal);
322+expect(restartParams?.isCancelled?.()).toBe(false);
323+abortController.abort();
324+expect(restartParams?.isCancelled?.()).toBe(true);
325+expect(clearGmailRestartAbortController).toHaveBeenCalledWith(abortController);
326+});
327+328+it("aborts an in-flight managed Gmail restart when the reloader stops", async () => {
329+const writeListenerRef: { current: ((event: ConfigWriteNotification) => void) | null } = {
330+current: null,
331+};
332+let restartSignal: AbortSignal | undefined;
333+let restartEntered: (() => void) | undefined;
334+const restartStarted = new Promise<void>((resolve) => {
335+restartEntered = resolve;
336+});
337+hoisted.startGmailWatcherWithLogs.mockImplementationOnce(
338+async (params: GmailWatcherRestartParams) => {
339+restartSignal = params.signal;
340+restartEntered?.();
341+await new Promise<void>((resolve) => {
342+params.signal?.addEventListener("abort", () => resolve(), { once: true });
343+});
344+},
345+);
346+const initialConfig = createGmailConfig("old@example.com");
347+const nextConfig = createGmailConfig("next@example.com");
348+const readSnapshot = vi.fn(async () => ({
349+path: "/tmp/openclaw.json",
350+exists: true,
351+raw: "{}",
352+parsed: {},
353+sourceConfig: nextConfig,
354+resolved: nextConfig,
355+valid: true,
356+runtimeConfig: nextConfig,
357+config: nextConfig,
358+issues: [],
359+warnings: [],
360+legacyIssues: [],
361+hash: "hash-next",
362+}));
363+const reloader = startManagedGatewayConfigReloader({
364+minimalTestGateway: false,
365+ initialConfig,
366+initialCompareConfig: initialConfig,
367+initialInternalWriteHash: null,
368+watchPath: "/tmp/openclaw.json",
369+readSnapshot: readSnapshot as never,
370+promoteSnapshot: vi.fn(async () => true) as never,
371+subscribeToWrites: ((listener: (event: ConfigWriteNotification) => void) => {
372+writeListenerRef.current = listener;
373+return () => {
374+if (writeListenerRef.current === listener) {
375+writeListenerRef.current = null;
376+}
377+};
378+}) as never,
379+deps: {} as never,
380+broadcast: vi.fn(),
381+getState: () => ({
382+hooksConfig: {} as never,
383+hookClientIpConfig: {} as never,
384+heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
385+cronState: {
386+cron: { start: vi.fn(async () => {}), stop: vi.fn() },
387+storePath: "/tmp/cron.json",
388+cronEnabled: false,
389+} as never,
390+channelHealthMonitor: null,
391+}),
392+setState: vi.fn(),
393+startChannel: vi.fn(async () => {}),
394+stopChannel: vi.fn(async () => {}),
395+reloadPlugins: vi.fn(
396+async (): Promise<GatewayPluginReloadResult> => ({
397+restartChannels: new Set(),
398+activeChannels: new Set(),
399+}),
400+),
401+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
402+logChannels: { info: vi.fn(), error: vi.fn() },
403+logCron: { error: vi.fn() },
404+logReload: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
405+channelManager: {} as never,
406+activateRuntimeSecrets: vi.fn(async (config: OpenClawConfig) => ({
407+sourceConfig: config,
408+ config,
409+authStores: [],
410+warnings: [],
411+webTools: {},
412+})) as never,
413+resolveSharedGatewaySessionGenerationForConfig: () => undefined,
414+sharedGatewaySessionGenerationState: { current: undefined, required: null },
415+clients: [],
416+});
417+const registeredWriteListener = writeListenerRef.current;
418+if (!registeredWriteListener) {
419+throw new Error("Expected config write listener to be registered");
420+}
421+422+registeredWriteListener({
423+configPath: "/tmp/openclaw.json",
424+sourceConfig: nextConfig,
425+runtimeConfig: nextConfig,
426+persistedHash: "hash-next",
427+revision: 1,
428+fingerprint: "runtime-hash-next",
429+sourceFingerprint: "source-hash-next",
430+writtenAtMs: Date.now(),
431+});
432+await restartStarted;
433+expect(restartSignal?.aborted).toBe(false);
434+435+await reloader.stop();
436+437+expect(restartSignal?.aborted).toBe(true);
438+});
439+440+it("does not start a Gmail restart after the managed reloader stops before hot reload applies", async () => {
441+const writeListenerRef: { current: ((event: ConfigWriteNotification) => void) | null } = {
442+current: null,
443+};
444+let releaseSecrets: (() => void) | undefined;
445+let secretsEntered: (() => void) | undefined;
446+const secretsStarted = new Promise<void>((resolve) => {
447+secretsEntered = resolve;
448+});
449+const releaseSecretsPromise = new Promise<void>((resolve) => {
450+releaseSecrets = resolve;
451+});
452+const initialConfig = createGmailConfig("old@example.com");
453+const nextConfig = createGmailConfig("next@example.com");
454+const reloader = startManagedGatewayConfigReloader({
455+minimalTestGateway: false,
456+ initialConfig,
457+initialCompareConfig: initialConfig,
458+initialInternalWriteHash: null,
459+watchPath: "/tmp/openclaw.json",
460+readSnapshot: vi.fn(async () => ({
461+path: "/tmp/openclaw.json",
462+exists: true,
463+raw: "{}",
464+parsed: {},
465+sourceConfig: nextConfig,
466+resolved: nextConfig,
467+valid: true,
468+runtimeConfig: nextConfig,
469+config: nextConfig,
470+issues: [],
471+warnings: [],
472+legacyIssues: [],
473+hash: "hash-next",
474+})) as never,
475+promoteSnapshot: vi.fn(async () => true) as never,
476+subscribeToWrites: ((listener: (event: ConfigWriteNotification) => void) => {
477+writeListenerRef.current = listener;
478+return () => {
479+if (writeListenerRef.current === listener) {
480+writeListenerRef.current = null;
481+}
482+};
483+}) as never,
484+deps: {} as never,
485+broadcast: vi.fn(),
486+getState: () => ({
487+hooksConfig: {} as never,
488+hookClientIpConfig: {} as never,
489+heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
490+cronState: {
491+cron: { start: vi.fn(async () => {}), stop: vi.fn() },
492+storePath: "/tmp/cron.json",
493+cronEnabled: false,
494+} as never,
495+channelHealthMonitor: null,
496+}),
497+setState: vi.fn(),
498+startChannel: vi.fn(async () => {}),
499+stopChannel: vi.fn(async () => {}),
500+reloadPlugins: vi.fn(
501+async (): Promise<GatewayPluginReloadResult> => ({
502+restartChannels: new Set(),
503+activeChannels: new Set(),
504+}),
505+),
506+logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
507+logChannels: { info: vi.fn(), error: vi.fn() },
508+logCron: { error: vi.fn() },
509+logReload: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
510+channelManager: {} as never,
511+activateRuntimeSecrets: vi.fn(async (config: OpenClawConfig) => {
512+secretsEntered?.();
513+await releaseSecretsPromise;
514+return {
515+sourceConfig: config,
516+ config,
517+authStores: [],
518+warnings: [],
519+webTools: {},
520+};
521+}) as never,
522+resolveSharedGatewaySessionGenerationForConfig: () => undefined,
523+sharedGatewaySessionGenerationState: { current: undefined, required: null },
524+clients: [],
525+});
526+const registeredWriteListener = writeListenerRef.current;
527+if (!registeredWriteListener) {
528+throw new Error("Expected config write listener to be registered");
529+}
530+531+registeredWriteListener({
532+configPath: "/tmp/openclaw.json",
533+sourceConfig: nextConfig,
534+runtimeConfig: nextConfig,
535+persistedHash: "hash-next",
536+revision: 1,
537+fingerprint: "runtime-hash-next",
538+sourceFingerprint: "source-hash-next",
539+writtenAtMs: Date.now(),
540+});
541+await secretsStarted;
542+543+const stopPromise = reloader.stop();
544+releaseSecrets?.();
545+await stopPromise;
546+await new Promise<void>((resolve) => setImmediate(resolve));
547+548+expect(hoisted.stopGmailWatcher).not.toHaveBeenCalled();
549+expect(hoisted.startGmailWatcherWithLogs).not.toHaveBeenCalled();
550+});
235551});
236552237553describe("gateway plugin hot reload handlers", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。