Skip to content

Commit b3f8a0e

Browse files
Kaspreclaude
andauthored
fix(plugin-sdk): use Function.name to find onDiagnosticEvent export (#87084)
* fix(plugin-sdk): use Function.name to find onDiagnosticEvent export normalizeDiagnosticEventsModule hardcodes `mod.r` as the fallback alias for onDiagnosticEvent, but the bundler reassigns export aliases across builds. On 2026.5.25-beta.1, `r` is emitFailoverEvent — calling it as onDiagnosticEvent returns a non-function, so the combo unsubscribe closure throws TypeError on every gateway stop. Replace the hardcoded letter with Function.name introspection. JS functions retain their original .name regardless of export aliasing, so this survives bundler alias changes. Fixes #87082 Co-Authored-By: Claude Opus 4.7 <[email protected]> * test(plugin-sdk): cover diagnostic event alias shifts * fix(plugin-sdk): harden diagnostic alias cleanup --------- Co-authored-by: Claude Opus 4.7 <[email protected]>
1 parent df6ec28 commit b3f8a0e

2 files changed

Lines changed: 237 additions & 5 deletions

File tree

src/plugin-sdk/root-alias.cjs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,55 @@ function onDiagnosticEventFromSharedState(listener) {
147147
};
148148
}
149149

150+
function snapshotDiagnosticListeners(state) {
151+
return state && state.listeners instanceof Set ? new Set(state.listeners) : null;
152+
}
153+
154+
function removeAddedDiagnosticListeners(beforeListeners) {
155+
const state = getDiagnosticEventsState(false);
156+
if (!state || !(state.listeners instanceof Set)) {
157+
return;
158+
}
159+
if (!beforeListeners) {
160+
state.listeners.clear();
161+
return;
162+
}
163+
for (const listener of state.listeners) {
164+
if (!beforeListeners.has(listener)) {
165+
state.listeners.delete(listener);
166+
}
167+
}
168+
}
169+
170+
function trySubscribeDiagnosticEvents(diagnosticEvents, listener, beforeListeners) {
171+
try {
172+
const unsubscribe = diagnosticEvents.onDiagnosticEvent(listener);
173+
if (typeof unsubscribe === "function") {
174+
return unsubscribe;
175+
}
176+
} catch {
177+
// Fall back to shared state if a stale dist chunk exposes a broken wrapper.
178+
}
179+
removeAddedDiagnosticListeners(beforeListeners);
180+
return null;
181+
}
182+
150183
function onDiagnosticEvent(listener) {
151184
const beforeState = getDiagnosticEventsState(false);
185+
const beforeListeners = snapshotDiagnosticListeners(beforeState);
152186
const beforeSize = beforeState?.listeners?.size;
153187
const diagnosticEvents = loadDiagnosticEventsModule();
154188
if (!diagnosticEvents || typeof diagnosticEvents.onDiagnosticEvent !== "function") {
155189
return onDiagnosticEventFromSharedState(listener);
156190
}
157-
const unsubscribeDiagnosticEvents = diagnosticEvents.onDiagnosticEvent(listener);
191+
const unsubscribeDiagnosticEvents = trySubscribeDiagnosticEvents(
192+
diagnosticEvents,
193+
listener,
194+
beforeListeners,
195+
);
196+
if (!unsubscribeDiagnosticEvents) {
197+
return onDiagnosticEventFromSharedState(listener);
198+
}
158199
const afterState = getDiagnosticEventsState(false);
159200
if (afterState && afterState.listeners.size > (beforeSize ?? 0)) {
160201
return unsubscribeDiagnosticEvents;
@@ -163,8 +204,11 @@ function onDiagnosticEvent(listener) {
163204
// diagnostic module in a separate graph from the active core emitter.
164205
const unsubscribeSharedState = onDiagnosticEventFromSharedState(listener);
165206
return () => {
166-
unsubscribeDiagnosticEvents();
167-
unsubscribeSharedState();
207+
try {
208+
unsubscribeDiagnosticEvents();
209+
} finally {
210+
unsubscribeSharedState();
211+
}
168212
};
169213
}
170214

@@ -362,10 +406,13 @@ function normalizeDiagnosticEventsModule(mod) {
362406
if (typeof mod.onDiagnosticEvent === "function") {
363407
return mod;
364408
}
365-
if (typeof mod.r === "function") {
409+
const fn = Object.values(mod).find(
410+
(v) => typeof v === "function" && v.name === "onDiagnosticEvent",
411+
);
412+
if (fn) {
366413
return {
367414
...mod,
368-
onDiagnosticEvent: mod.r,
415+
onDiagnosticEvent: fn,
369416
};
370417
}
371418
return mod;

src/plugins/contracts/plugin-sdk-root-alias.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const rootSdk = require(rootAliasPath) as Record<string, unknown>;
1111
const rootAliasSource = fs.readFileSync(rootAliasPath, "utf-8");
1212
const compatPath = fileURLToPath(new URL("../../plugin-sdk/compat.ts", import.meta.url));
1313
const packageJsonPath = fileURLToPath(new URL("../../../package.json", import.meta.url));
14+
const diagnosticEventsStateKey = Symbol.for("openclaw.diagnosticEvents.state.v1");
1415
const legacyRootExportNames = [
1516
"registerContextEngine",
1617
"buildMemorySystemPromptAddition",
@@ -36,6 +37,10 @@ type EmptySchema = {
3637
};
3738
};
3839

40+
type DiagnosticEventsStateFixture = {
41+
listeners: Set<(event: { type: string }, metadata: { trusted: boolean }) => void>;
42+
};
43+
3944
function requirePropertyDescriptor(
4045
target: Record<string, unknown>,
4146
propertyName: string,
@@ -183,6 +188,56 @@ function loadDiagnosticEventsAlias(distEntries: string[]) {
183188
});
184189
}
185190

191+
function ensureDiagnosticEventsStateFixture(
192+
context: Record<PropertyKey, unknown>,
193+
): DiagnosticEventsStateFixture {
194+
const existing = context[diagnosticEventsStateKey] as DiagnosticEventsStateFixture | undefined;
195+
if (existing) {
196+
return existing;
197+
}
198+
const state = vm.runInNewContext(
199+
`({
200+
marker: Symbol.for("openclaw.diagnosticEvents.state.v1"),
201+
enabled: true,
202+
seq: 0,
203+
listeners: new Set(),
204+
dispatchDepth: 0,
205+
asyncQueue: [],
206+
asyncDrainScheduled: false,
207+
asyncDroppedEvents: 0,
208+
asyncDroppedTrustedEvents: 0,
209+
asyncDroppedUntrustedEvents: 0,
210+
asyncDroppedPriorityEvents: 0,
211+
})`,
212+
context,
213+
) as DiagnosticEventsStateFixture;
214+
Object.defineProperty(context, diagnosticEventsStateKey, {
215+
configurable: true,
216+
enumerable: false,
217+
value: state,
218+
writable: false,
219+
});
220+
return state;
221+
}
222+
223+
function requireDiagnosticEventsStateFixture(
224+
lazyModule: ReturnType<typeof loadRootAliasWithStubs>,
225+
): DiagnosticEventsStateFixture {
226+
const state = lazyModule.globalContext[diagnosticEventsStateKey] as
227+
| DiagnosticEventsStateFixture
228+
| undefined;
229+
if (!state) {
230+
throw new Error("expected diagnostic events state fixture");
231+
}
232+
return state;
233+
}
234+
235+
function emitFixtureDiagnosticEvent(state: DiagnosticEventsStateFixture): void {
236+
for (const registered of state.listeners) {
237+
registered({ type: "model.usage" }, { trusted: false });
238+
}
239+
}
240+
186241
function expectDiagnosticEventAccessor(lazyModule: ReturnType<typeof loadRootAliasWithStubs>) {
187242
expect(
188243
typeof (lazyModule.moduleExports.onDiagnosticEvent as (listener: () => void) => () => void)(
@@ -525,6 +580,136 @@ describe("plugin-sdk root alias", () => {
525580
);
526581
});
527582

583+
it("resolves the diagnostic event export by function name when dist aliases shift", () => {
584+
let subscribeCount = 0;
585+
let unsubscribeCount = 0;
586+
const lazyModule = loadRootAliasWithStubs({
587+
aliasPath: createDistAliasPath(),
588+
distEntries: ["diagnostic-events-W3Hz61fI.js"],
589+
monolithicExports: {
590+
r: function emitFailoverEvent(): void {
591+
throw new Error("wrong diagnostic event alias selected");
592+
},
593+
u: function onDiagnosticEvent(_listener: () => void): () => void {
594+
subscribeCount += 1;
595+
return () => {
596+
unsubscribeCount += 1;
597+
};
598+
},
599+
},
600+
});
601+
602+
const unsubscribe = (
603+
lazyModule.moduleExports.onDiagnosticEvent as (
604+
listener: (event: { type: string }) => void,
605+
) => () => void
606+
)(() => undefined);
607+
unsubscribe();
608+
609+
expect(subscribeCount).toBe(1);
610+
expect(unsubscribeCount).toBe(1);
611+
});
612+
613+
it("falls back and removes stale diagnostic listeners when the dist subscription is invalid", () => {
614+
const seen: string[] = [];
615+
let lazyModule!: ReturnType<typeof loadRootAliasWithStubs>;
616+
const preexistingListener = (): void => undefined;
617+
lazyModule = loadRootAliasWithStubs({
618+
aliasPath: createDistAliasPath(),
619+
distEntries: ["diagnostic-events-W3Hz61fI.js"],
620+
monolithicExports: {
621+
onDiagnosticEvent(listener: (event: { type: string }) => void): undefined {
622+
const state = ensureDiagnosticEventsStateFixture(lazyModule.globalContext);
623+
state.listeners.add((event, metadata) => {
624+
if (!metadata.trusted) {
625+
listener(event);
626+
}
627+
});
628+
return undefined;
629+
},
630+
},
631+
});
632+
const state = ensureDiagnosticEventsStateFixture(lazyModule.globalContext);
633+
state.listeners.add(preexistingListener);
634+
635+
const unsubscribe = (
636+
lazyModule.moduleExports.onDiagnosticEvent as (
637+
listener: (event: { type: string }) => void,
638+
) => () => void
639+
)((event) => {
640+
seen.push(event.type);
641+
});
642+
643+
expect(state.listeners.size).toBe(2);
644+
expect(state.listeners.has(preexistingListener)).toBe(true);
645+
emitFixtureDiagnosticEvent(state);
646+
unsubscribe();
647+
648+
expect(seen).toEqual(["model.usage"]);
649+
expect(state.listeners.size).toBe(1);
650+
expect(state.listeners.has(preexistingListener)).toBe(true);
651+
});
652+
653+
it("falls back to shared diagnostic state when the dist subscription throws", () => {
654+
const seen: string[] = [];
655+
let subscribeCount = 0;
656+
const lazyModule = loadRootAliasWithStubs({
657+
aliasPath: createDistAliasPath(),
658+
distEntries: ["diagnostic-events-W3Hz61fI.js"],
659+
monolithicExports: {
660+
onDiagnosticEvent(): never {
661+
subscribeCount += 1;
662+
throw new Error("stale diagnostic subscription");
663+
},
664+
},
665+
});
666+
667+
const unsubscribe = (
668+
lazyModule.moduleExports.onDiagnosticEvent as (
669+
listener: (event: { type: string }) => void,
670+
) => () => void
671+
)((event) => {
672+
seen.push(event.type);
673+
});
674+
const state = requireDiagnosticEventsStateFixture(lazyModule);
675+
676+
expect(subscribeCount).toBe(1);
677+
expect(state.listeners.size).toBe(1);
678+
emitFixtureDiagnosticEvent(state);
679+
unsubscribe();
680+
681+
expect(seen).toEqual(["model.usage"]);
682+
expect(state.listeners.size).toBe(0);
683+
});
684+
685+
it("removes the shared-state fallback listener when diagnostic cleanup throws", () => {
686+
let diagnosticUnsubscribeCount = 0;
687+
const lazyModule = loadRootAliasWithStubs({
688+
aliasPath: createDistAliasPath(),
689+
distEntries: ["diagnostic-events-W3Hz61fI.js"],
690+
monolithicExports: {
691+
onDiagnosticEvent(): () => void {
692+
return () => {
693+
diagnosticUnsubscribeCount += 1;
694+
throw new Error("diagnostic cleanup failed");
695+
};
696+
},
697+
},
698+
});
699+
700+
const unsubscribe = (
701+
lazyModule.moduleExports.onDiagnosticEvent as (
702+
listener: (event: { type: string }) => void,
703+
) => () => void
704+
)(() => undefined);
705+
const state = requireDiagnosticEventsStateFixture(lazyModule);
706+
707+
expect(state.listeners.size).toBe(1);
708+
expect(() => unsubscribe()).toThrow("diagnostic cleanup failed");
709+
expect(diagnosticUnsubscribeCount).toBe(1);
710+
expect(state.listeners.size).toBe(0);
711+
});
712+
528713
it("bridges diagnostic listeners through shared process state when the lazy module is isolated", () => {
529714
const seen: string[] = [];
530715
const lazyModule = loadDiagnosticEventsAlias(["diagnostic-events-W3Hz61fI.js"]);

0 commit comments

Comments
 (0)