fix(plugins): share split-load singleton state (openclaw#50418) thanks @huntharo

Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
This commit is contained in:
Harold Hunt 2026-03-19 09:10:24 -04:00 committed by GitHub
parent 7f86be1037
commit 5508374669
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 19 deletions

View File

@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
### Breaking

View File

@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type ActiveListenerModule = typeof import("./active-listener.js");
const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href;
async function importActiveListenerModule(cacheBust: string): Promise<ActiveListenerModule> {
return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule;
}
afterEach(async () => {
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
mod.setActiveWebListener(null);
mod.setActiveWebListener("work", null);
});
describe("active WhatsApp listener singleton", () => {
it("shares listeners across duplicate module instances", async () => {
const first = await importActiveListenerModule(`first-${Date.now()}`);
const second = await importActiveListenerModule(`second-${Date.now()}`);
const listener = {
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
sendReaction: vi.fn(async () => {}),
sendComposingTo: vi.fn(async () => {}),
};
first.setActiveWebListener("work", listener);
expect(second.getActiveWebListener("work")).toBe(listener);
expect(second.requireActiveWebListener("work")).toEqual({
accountId: "work",
listener,
});
});
});

View File

@ -28,27 +28,22 @@ export type ActiveWebListener = {
close?: () => Promise<void>;
};
// Use a process-level singleton to survive bundler code-splitting.
// Rolldown duplicates this module across multiple output chunks, each with its
// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's
// Map via setActiveWebListener(), but the outbound send path reads from a
// different chunk's Map via requireActiveWebListener() — so the listener is
// never found. Pinning the Map to globalThis ensures all chunks share one
// instance. See: https://github.com/openclaw/openclaw/issues/14406
const GLOBAL_KEY = "__openclaw_wa_listeners" as const;
const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const;
// Use process-global symbol keys to survive bundler code-splitting and loader
// cache splits without depending on fragile string property names.
const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners");
const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener");
type GlobalWithListeners = typeof globalThis & {
[GLOBAL_KEY]?: Map<string, ActiveWebListener>;
[GLOBAL_LISTENERS_KEY]?: Map<string, ActiveWebListener>;
[GLOBAL_CURRENT_KEY]?: ActiveWebListener | null;
};
const _global = globalThis as GlobalWithListeners;
_global[GLOBAL_KEY] ??= new Map<string, ActiveWebListener>();
_global[GLOBAL_LISTENERS_KEY] ??= new Map<string, ActiveWebListener>();
_global[GLOBAL_CURRENT_KEY] ??= null;
const listeners = _global[GLOBAL_KEY];
const listeners = _global[GLOBAL_LISTENERS_KEY];
function getCurrentListener(): ActiveWebListener | null {
return _global[GLOBAL_CURRENT_KEY] ?? null;

View File

@ -12,6 +12,14 @@ import {
} from "./commands.js";
import { setActivePluginRegistry } from "./runtime.js";
type CommandsModule = typeof import("./commands.js");
const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href;
async function importCommandsModule(cacheBust: string): Promise<CommandsModule> {
return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule;
}
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
@ -108,6 +116,40 @@ describe("registerPluginCommand", () => {
expect(getPluginCommandSpecs("slack")).toEqual([]);
});
it("shares plugin commands across duplicate module instances", async () => {
const first = await importCommandsModule(`first-${Date.now()}`);
const second = await importCommandsModule(`second-${Date.now()}`);
first.clearPluginCommands();
expect(
first.registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
telegram: "voice",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({ ok: true });
expect(second.getPluginCommandSpecs("telegram")).toEqual([
{
name: "voice",
description: "Voice command",
acceptsArgs: false,
},
]);
expect(second.matchPluginCommand("/voice")).toMatchObject({
command: expect.objectContaining({
name: "voice",
pluginId: "demo-plugin",
}),
});
second.clearPluginCommands();
});
it("matches provider-specific native aliases back to the canonical command", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",

View File

@ -8,6 +8,7 @@
import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js";
import type { OpenClawConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import {
detachPluginConversationBinding,
getCurrentPluginConversationBinding,
@ -25,11 +26,19 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
pluginRoot?: string;
};
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
type PluginCommandState = {
pluginCommands: Map<string, RegisteredPluginCommand>;
registryLocked: boolean;
};
// Lock to prevent modifications during command execution
let registryLocked = false;
const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState");
const state = resolveGlobalSingleton<PluginCommandState>(PLUGIN_COMMAND_STATE_KEY, () => ({
pluginCommands: new Map<string, RegisteredPluginCommand>(),
registryLocked: false,
}));
const pluginCommands = state.pluginCommands;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
@ -172,7 +181,7 @@ export function registerPluginCommand(
opts?: { pluginName?: string; pluginRoot?: string },
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLocked) {
if (state.registryLocked) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
@ -451,7 +460,7 @@ export async function executePluginCommand(params: {
};
// Lock registry during execution to prevent concurrent modifications
registryLocked = true;
state.registryLocked = true;
try {
const result = await command.handler(ctx);
logVerbose(
@ -464,7 +473,7 @@ export async function executePluginCommand(params: {
// Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLocked = false;
state.registryLocked = false;
}
}