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:
parent
7f86be1037
commit
5508374669
@ -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
|
||||
|
||||
|
||||
36
extensions/whatsapp/src/active-listener.test.ts
Normal file
36
extensions/whatsapp/src/active-listener.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user