Skip to content

Commit 8da1953

Browse files
committed
fix(plugin-sdk): harden diagnostic alias cleanup
1 parent 2ed9d48 commit 8da1953

2 files changed

Lines changed: 202 additions & 3 deletions

File tree

src/plugin-sdk/root-alias.cjs

Lines changed: 47 additions & 3 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

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

Lines changed: 155 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)(
@@ -555,6 +610,106 @@ describe("plugin-sdk root alias", () => {
555610
expect(unsubscribeCount).toBe(1);
556611
});
557612

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+
558713
it("bridges diagnostic listeners through shared process state when the lazy module is isolated", () => {
559714
const seen: string[] = [];
560715
const lazyModule = loadDiagnosticEventsAlias(["diagnostic-events-W3Hz61fI.js"]);

0 commit comments

Comments
 (0)