From e455c0aabfc23f6439d235b2860ccf930faf2985 Mon Sep 17 00:00:00 2001 From: liuzixiang Date: Mon, 19 Oct 2026 12:09:14 +0800 Subject: [PATCH 1/4] fix(plugins): keep session follow alive across renderer reloads and inside scope Two defects in the #1745 follow feature: - The out-of-order guard kept a main-side high-water mark over a counter the renderer owns in a useRef. A renderer reload (the automatic recovery for render-process-gone restarts the counter) left the mark stale, so every push afterwards was silently ignored and plugins saw the pre-reload session forever (#1753). Pushes now carry a per-load boot id; a changed boot id resets the mark, and the out-of-order guard still applies within a boot. - session:activated fanned out to every loaded plugin process and docked view without the activation-scope check that session.getActive and ui.revealView enforce, so a project-scoped plugin received other projects' session identity its pull API refuses to give (#1754). The fan-out now scope-filters selection events per recipient, using the session's project from the live map and the workspace-relative check when the session has none. --- apps/desktop/electron/main/index.ts | 1 + .../electron/main/ipc/plugin-ui-ipc.ts | 19 ++++- apps/desktop/electron/main/plugin-runtime.ts | 3 +- .../desktop/electron/main/plugin-view-host.ts | 3 +- .../electron/main/services/plugin-services.ts | 24 +++++- .../src/features/app/useAppShellRuntime.tsx | 4 + apps/desktop/src/lib/api.ts | 3 + .../test/plugin-session-follow.test.mjs | 83 +++++++++++++++++++ 8 files changed, 133 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index a478bb597c..f5addefd50 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -304,6 +304,7 @@ let sessionLaunchRuntime: ReturnType | null = const pluginServices = createPluginServices({ dataDir, logger, + pluginActiveInProject, getMainWindow, getHost, sendToRenderer, diff --git a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts index 199ad72f25..7847075d82 100644 --- a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts +++ b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts @@ -49,6 +49,10 @@ export function registerPluginUiIpc({ * after a newer one cannot restore a session the user already left. */ let activeSessionSeq = 0; + /** The renderer load the high-water mark belongs to. A reload restarts the + * renderer's counter from one, so a changed boot id resets the mark and + * pushes keep flowing afterwards (#1753). */ + let activeSessionBootId: string | null = null; /** * The session on screen, as the shell sees it. * @@ -63,12 +67,21 @@ export function registerPluginUiIpc({ IPC.invoke.pluginSessionActive, async ( payload: - | { sessionId?: string | null; projectPath?: string | null; seq?: number | null } + | { sessionId?: string | null; projectPath?: string | null; seq?: number | null; bootId?: string | null } | null, ) => { const seq = typeof payload?.seq === "number" ? payload.seq : 0; - if (seq > 0 && seq < activeSessionSeq) return { ok: true, ignored: true }; - if (seq > 0) activeSessionSeq = seq; + const bootId = typeof payload?.bootId === "string" ? payload.bootId : null; + if (seq > 0) { + if (bootId && bootId !== activeSessionBootId) { + activeSessionBootId = bootId; + activeSessionSeq = seq; + } else if (seq >= activeSessionSeq) { + activeSessionSeq = seq; + } else { + return { ok: true, ignored: true }; + } + } const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId.trim() : ""; if (!sessionId) { setActivePluginSession(null); diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index f98f190a38..fb3e4859ae 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -1994,8 +1994,9 @@ export class PluginRuntime { * receive the same names through `pluginBridge.on`; this is the process * half (spec 07 §5). */ - broadcastEvent(event: string, args: unknown[] = []): void { + broadcastEvent(event: string, args: unknown[] = [], filter?: (pluginId: string) => boolean): void { for (const loaded of this.loaded.values()) { + if (filter && !filter(loaded.manifest.id)) continue; try { loaded.child?.postMessage({ t: "event", event, args }); } catch (error) { diff --git a/apps/desktop/electron/main/plugin-view-host.ts b/apps/desktop/electron/main/plugin-view-host.ts index 9375cd97f9..a4976d3d1e 100644 --- a/apps/desktop/electron/main/plugin-view-host.ts +++ b/apps/desktop/electron/main/plugin-view-host.ts @@ -141,9 +141,10 @@ export class PluginViewHost { * are broadcast separately by `PluginPanelHost`; both surfaces share the * preload channel `pi-plugin-panel-event:`. */ - broadcast(event: string, payload: unknown): void { + broadcast(event: string, payload: unknown, filter?: (pluginId: string) => boolean): void { const channel = `pi-plugin-panel-event:${event}`; for (const entry of this.views.values()) { + if (filter && !filter(entry.pluginId)) continue; const wc = entry.view.webContents; if (wc.isDestroyed()) continue; try { diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index fea3cec9f5..5d1ffb6b3c 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -85,6 +85,12 @@ export type PluginServicesDependencies = { * Scope records live in Main, so this is the one lookup that decides. */ isPluginActiveInProject: (pluginId: string) => boolean; + /** + * Scope check against an explicit project path, so the session-event + * fan-out can hold the same line `session.getActive` does for sessions + * that are not the window's own selection (#1754). + */ + pluginActiveInProject?: (pluginId: string, projectPath: string | null | undefined) => boolean; }; /** @@ -114,6 +120,7 @@ export function createPluginServices({ vendorOAuth, getActiveTurn, isPluginActiveInProject, + pluginActiveInProject, }: PluginServicesDependencies) { // A plugin request can lose its race with host shutdown or restart. const isHostUnavailable = (error: unknown): boolean => @@ -575,8 +582,21 @@ export function createPluginServices({ const broadcastSessionEvent = (event: string, payload: unknown): void => { const sessionId = (payload as { sessionId?: string } | null | undefined)?.sessionId ?? undefined; + // Selection events carry another project's session identity, so the + // fan-out holds the scope line the pull side (session.getActive, + // ui.revealView) already enforces: a project-scoped plugin does not + // receive a session its scope excludes (#1754). Sessions without a + // project fall back to the workspace-relative check. + const scopeFilter = + SESSION_SCOPED_EVENTS.has(event) && pluginActiveInProject + ? (pluginId: string) => + pluginActiveInProject( + pluginId, + (sessionId && sessionProjects.get(sessionId)) ?? null, + ) + : undefined; try { - plugins.broadcastEvent(event, [payload]); + plugins.broadcastEvent(event, [payload], scopeFilter); } catch (error) { logger.app("plugin", "warn", `${event} plugin broadcast failed`, { sessionId, @@ -594,7 +614,7 @@ export function createPluginServices({ } } try { - pluginViews.broadcast(event, payload); + pluginViews.broadcast(event, payload, scopeFilter); } catch (error) { logger.app("plugin", "warn", `${event} view broadcast failed`, { sessionId, diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx index edc5404816..f7058af029 100644 --- a/apps/desktop/src/features/app/useAppShellRuntime.tsx +++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx @@ -74,6 +74,9 @@ export function useAppShellRuntime() { const projectPath = useAppStore((s) => s.workspace?.path ?? null); /** Monotonic counter for the plugin active-session push. */ const activeSessionSeq = useRef(0); + /** Identifies this renderer load: a reload restarts the counter, and the + * main-side high-water mark must restart with it (#1753). */ + const sessionPushBootId = useRef(crypto.randomUUID()); /** The project of the session on screen, for the plugin session snapshot. */ const activeSessionProjectPath = useAppStore((s) => s.activeSessionId @@ -476,6 +479,7 @@ export function useAppShellRuntime() { sessionId: activeSessionId ?? null, projectPath: activeSessionProjectPath, seq: activeSessionSeq.current, + bootId: sessionPushBootId.current, }) .catch(() => undefined); }, [activeSessionId, activeSessionProjectPath]); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index a23632a115..85810d88a6 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -684,6 +684,9 @@ export const api = { projectPath?: string | null; /** Renderer-side counter: a slower push must never undo a newer one. */ seq?: number; + /** Identifies the renderer load: a reload restarts the counter, and the + * main-side high-water mark restarts with it (#1753). */ + bootId?: string; }) => invoke<{ ok: boolean }>(IPC.invoke.pluginSessionActive, input), listSessions: () => invoke<{ sessions: SessionSummary[] }>(IPC.invoke.sessionList).then((result) => ({ diff --git a/apps/desktop/test/plugin-session-follow.test.mjs b/apps/desktop/test/plugin-session-follow.test.mjs index 2160b8caa2..a6c5ae3388 100644 --- a/apps/desktop/test/plugin-session-follow.test.mjs +++ b/apps/desktop/test/plugin-session-follow.test.mjs @@ -322,3 +322,86 @@ test("turn start times stay bounded when a turn ends without a notice", () => { context.setActiveSession({ sessionId: "session-0", projectPath: null }); assert.equal(context.snapshot().activeTurn, null); }); + +test("broadcastEvent's filter keeps an out-of-scope plugin from receiving the session push", async (t) => { + const sent = []; + const spawnWithSpy = (options) => { + const proc = forkPluginProcess(options); + const ownerId = currentId; + const originalPostMessage = proc.postMessage; + proc.postMessage = (message) => { + sent.push({ plugin: ownerId, message }); + originalPostMessage(message); + }; + return proc; + }; + let currentId = "demo.in-scope"; + const runtime = new PluginRuntime({ + hostEntry: hostProcessEntry, + spawnProcess: spawnWithSpy, + isPluginActiveInProject: (pluginId) => pluginId === "demo.in-scope", + }); + t.after(async () => { + for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id); + }); + + await runtime.loadFromPath( + writeViewPlugin({ + id: "demo.in-scope", + permissions: ["ui.view"], + views: [CHANGES_VIEW], + main: "module.exports = { async onLoad() {} };", + }), + ["ui.view"], + ); + currentId = "demo.out-of-scope"; + await runtime.loadFromPath( + writeViewPlugin({ + id: "demo.out-of-scope", + permissions: ["ui.view"], + views: [CHANGES_VIEW], + main: "module.exports = { async onLoad() {} };", + }), + ["ui.view"], + ); + + const payload = { sessionId: "s-other", projectPath: "/work/other" }; + runtime.broadcastEvent( + "session:activated", + [payload], + (pluginId) => pluginId === "demo.in-scope", + ); + await new Promise((resolve) => setImmediate(resolve)); + + const events = sent.filter((entry) => entry.message?.event === "session:activated"); + assert.equal(events.length, 1, "exactly the in-scope plugin process received the push"); + assert.equal(events[0].plugin, "demo.in-scope"); + assert.deepEqual(events[0].message.args, [payload]); +}); + +test("the session-push gate restarts with the renderer's boot id and the payload carries it", async () => { + const { readMainSource, readStoreModule } = await import("./helpers/source-contracts.mjs"); + const ipcSource = await readMainSource("ipc/plugin-ui-ipc.ts"); + + // Main (#1753): a changed boot id resets the high-water mark, so pushes + // keep flowing after a renderer reload; within one boot the old + // out-of-order guard still applies. + assert.match( + ipcSource, + /const\s+bootId\s*=\s*typeof\s+payload\?\.bootId\s*===\s*"string"[\s\S]*?bootId\s*!==\s*activeSessionBootId[\s\S]*?activeSessionSeq\s*=\s*seq;[\s\S]*?return\s+\{\s*ok:\s*true,\s*ignored:\s*true\s*\};/, + "the gate must reset the high-water mark on a new renderer boot and keep the out-of-order guard within a boot", + ); + + // Renderer: the push carries the boot id the gate compares. + const runtimeSource = await readStoreModule("../../src/features/app/useAppShellRuntime.tsx"); + assert.match( + runtimeSource, + /sessionPushBootId\s*=\s*useRef\(crypto\.randomUUID\(\)\)/, + "the renderer must own a per-load boot id", + ); + assert.match( + runtimeSource, + /bootId:\s*sessionPushBootId\.current/, + "the push must carry the boot id", + ); +}); From ce8ffae8acafa0c9fc458b311088fa0fcaac3230 Mon Sep 17 00:00:00 2001 From: liuzixiang Date: Mon, 19 Oct 2026 18:47:02 +0800 Subject: [PATCH 2/4] fix(plugins): require the session scope check and update the wiring contracts Review follow-ups on #1756: make pluginActiveInProject a required dependency of plugin services (an optional one let a future caller forget the wiring and silently reintroduce #1754), document that the boot-id out-of-order guarantee is per renderer load, and widen the two main-wiring contracts to accept the optional scope-filter argument on the selection-event fan-out instead of pinning the old two-argument call shape. --- apps/desktop/electron/main/ipc/plugin-ui-ipc.ts | 4 +++- .../electron/main/services/plugin-services.ts | 17 ++++++++--------- apps/desktop/test/main-wiring-contract.test.mjs | 8 ++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts index 7847075d82..1d4f2860fe 100644 --- a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts +++ b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts @@ -51,7 +51,9 @@ export function registerPluginUiIpc({ let activeSessionSeq = 0; /** The renderer load the high-water mark belongs to. A reload restarts the * renderer's counter from one, so a changed boot id resets the mark and - * pushes keep flowing afterwards (#1753). */ + * pushes keep flowing afterwards (#1753). The boot id is per renderer + * load, so with two windows open the out-of-order guarantee is per window + * — only one window's session is on screen at a time. */ let activeSessionBootId: string | null = null; /** * The session on screen, as the shell sees it. diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index 5d1ffb6b3c..a989584f26 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -90,7 +90,7 @@ export type PluginServicesDependencies = { * fan-out can hold the same line `session.getActive` does for sessions * that are not the window's own selection (#1754). */ - pluginActiveInProject?: (pluginId: string, projectPath: string | null | undefined) => boolean; + pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean; }; /** @@ -587,14 +587,13 @@ export function createPluginServices({ // ui.revealView) already enforces: a project-scoped plugin does not // receive a session its scope excludes (#1754). Sessions without a // project fall back to the workspace-relative check. - const scopeFilter = - SESSION_SCOPED_EVENTS.has(event) && pluginActiveInProject - ? (pluginId: string) => - pluginActiveInProject( - pluginId, - (sessionId && sessionProjects.get(sessionId)) ?? null, - ) - : undefined; + const scopeFilter = SESSION_SCOPED_EVENTS.has(event) + ? (pluginId: string) => + pluginActiveInProject( + pluginId, + (sessionId && sessionProjects.get(sessionId)) ?? null, + ) + : undefined; try { plugins.broadcastEvent(event, [payload], scopeFilter); } catch (error) { diff --git a/apps/desktop/test/main-wiring-contract.test.mjs b/apps/desktop/test/main-wiring-contract.test.mjs index f29da9c740..32991d669e 100644 --- a/apps/desktop/test/main-wiring-contract.test.mjs +++ b/apps/desktop/test/main-wiring-contract.test.mjs @@ -204,9 +204,9 @@ test("a finished turn is announced to every plugin surface", () => { services, services.indexOf("{", services.indexOf("const broadcastSessionEvent")), ); - assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\]\)/); + assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\](?:, scopeFilter)?\)/); assert.match(fanOut, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(fanOut, /pluginViews\.broadcast\(event, payload\)/); + assert.match(fanOut, /pluginViews\.broadcast\(event, payload(?:, scopeFilter)?\)/); // Each surface owns its failure: one unreachable plugin process, panel or // view must not suppress the other two. assert.equal( @@ -228,9 +228,9 @@ test("the selected session is announced to every plugin surface", () => { // The snapshot is announced from the one module that owns it, and reaches the // same three surfaces as a finished turn. assert.match(context, /announce\("session:activated", next\)/); - assert.match(services, /plugins\.broadcastEvent\(event, \[payload\]\)/); + assert.match(services, /plugins\.broadcastEvent\(event, \[payload\](?:, scopeFilter)?\)/); assert.match(services, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(services, /pluginViews\.broadcast\(event, payload\)/); + assert.match(services, /pluginViews\.broadcast\(event, payload(?:, scopeFilter)?\)/); // Turn identity and start time come from the host, never from the renderer. assert.match( services, From c419e999be4b357aafc008812feba943c3d3e985 Mon Sep 17 00:00:00 2001 From: liuzixiang Date: Tue, 20 Oct 2026 09:42:44 +0800 Subject: [PATCH 3/4] test(plugins): observable gate unit, production scope filter, widened view contract Review follow-ups on #1756: the session-push gate moves into the dependency-free plugin-session-push-gate.ts so the reload acceptance and within-boot rejection are asserted directly (new boot resets the mark and low sequences flow; older sequences within a boot stay ignored); the scope-filter derivation moves into plugin-session-scope.ts and gains a unit test over the announced session's project (in-scope passes, out-of-scope fails, null project passes through null, other session events fan out unconditionally); and the work-panel-views contract accepts the optional filter argument on the view-host broadcast signature. --- .../electron/main/ipc/plugin-ui-ipc.ts | 20 ++----- .../electron/main/plugin-session-push-gate.ts | 27 ++++++++++ .../electron/main/plugin-session-scope.ts | 28 ++++++++++ .../electron/main/services/plugin-services.ts | 16 +++--- apps/desktop/test/plugin-services.test.mjs | 38 ++++++++++++++ .../test/plugin-session-follow.test.mjs | 52 ++++++++++++++++--- .../test/plugin-work-panel-views.test.mjs | 2 +- 7 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/electron/main/plugin-session-push-gate.ts create mode 100644 apps/desktop/electron/main/plugin-session-scope.ts diff --git a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts index 1d4f2860fe..c0d516a958 100644 --- a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts +++ b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts @@ -11,6 +11,7 @@ import { PluginPanelHost } from "../plugin-panel-host"; import type { PluginPanelTheme } from "../../shared/plugin-panel-chrome"; import type { IpcRegistrar } from "./types"; import { pluginProviderCatalogEntries } from "../plugin-provider-catalog"; +import { createSessionPushGate } from "../plugin-session-push-gate"; export type PluginUiIpcDependencies = { registrar: IpcRegistrar; @@ -48,13 +49,7 @@ export function registerPluginUiIpc({ * The newest selection the shell reported, so a slower invoke that lands * after a newer one cannot restore a session the user already left. */ - let activeSessionSeq = 0; - /** The renderer load the high-water mark belongs to. A reload restarts the - * renderer's counter from one, so a changed boot id resets the mark and - * pushes keep flowing afterwards (#1753). The boot id is per renderer - * load, so with two windows open the out-of-order guarantee is per window - * — only one window's session is on screen at a time. */ - let activeSessionBootId: string | null = null; + const sessionPushGate = createSessionPushGate(); /** * The session on screen, as the shell sees it. * @@ -74,15 +69,8 @@ export function registerPluginUiIpc({ ) => { const seq = typeof payload?.seq === "number" ? payload.seq : 0; const bootId = typeof payload?.bootId === "string" ? payload.bootId : null; - if (seq > 0) { - if (bootId && bootId !== activeSessionBootId) { - activeSessionBootId = bootId; - activeSessionSeq = seq; - } else if (seq >= activeSessionSeq) { - activeSessionSeq = seq; - } else { - return { ok: true, ignored: true }; - } + if (sessionPushGate({ seq, bootId }).ignored) { + return { ok: true, ignored: true }; } const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId.trim() : ""; if (!sessionId) { diff --git a/apps/desktop/electron/main/plugin-session-push-gate.ts b/apps/desktop/electron/main/plugin-session-push-gate.ts new file mode 100644 index 0000000000..2e6f03ca28 --- /dev/null +++ b/apps/desktop/electron/main/plugin-session-push-gate.ts @@ -0,0 +1,27 @@ +/** + * The out-of-order gate for plugin session pushes (#1753): within one + * renderer load a slower push must never undo a newer one, and a renderer + * reload (the automatic recovery for render-process-gone) restarts the + * renderer's counter — so a changed boot id resets the high-water mark + * instead of silently dropping every push afterwards. The boot id is per + * renderer load, so with two windows open the ordering guarantee is per + * window; only one window's session is on screen at a time. Kept dependency + * free so `plugin-session-follow.test.mjs` can drive it directly. + */ +export function createSessionPushGate() { + let activeSessionSeq = 0; + let activeSessionBootId: string | null = null; + return (push: { seq: number; bootId?: string | null }): { ok: boolean; ignored?: boolean } => { + if (push.seq > 0) { + if (push.bootId && push.bootId !== activeSessionBootId) { + activeSessionBootId = push.bootId; + activeSessionSeq = push.seq; + } else if (push.seq >= activeSessionSeq) { + activeSessionSeq = push.seq; + } else { + return { ok: true, ignored: true }; + } + } + return { ok: true }; + }; +} diff --git a/apps/desktop/electron/main/plugin-session-scope.ts b/apps/desktop/electron/main/plugin-session-scope.ts new file mode 100644 index 0000000000..0f534fbe62 --- /dev/null +++ b/apps/desktop/electron/main/plugin-session-scope.ts @@ -0,0 +1,28 @@ +/** + * Scope rules for plugin session events (#1754). Selection events carry + * another project's session identity, so their recipients are scope-checked + * against the announced session's project — the same line `session.getActive` + * enforces on the pull side. Kept dependency free so + * `plugin-services.test.mjs` can drive the filter directly. + */ + +/** Session events whose payload describes the user's current selection. */ +export const SESSION_SCOPED_EVENTS = new Set(["session:activated"]); + +/** + * The recipient filter for a session event, or `undefined` when the event + * fans out unconditionally. + */ +export function sessionEventScopeFilter( + event: string, + sessionId: string | undefined, + sessionProjects: ReadonlyMap, + pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean, +): ((pluginId: string) => boolean) | undefined { + if (!SESSION_SCOPED_EVENTS.has(event)) return undefined; + return (pluginId: string) => + pluginActiveInProject( + pluginId, + (sessionId && sessionProjects.get(sessionId)) ?? null, + ); +} diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index a989584f26..9fb135f361 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -48,6 +48,7 @@ import { import { McpOAuthManager } from "../mcp-oauth"; import { PluginPanelHost } from "../plugin-panel-host"; import { PluginViewHost } from "../plugin-view-host"; +import { SESSION_SCOPED_EVENTS, sessionEventScopeFilter } from "../plugin-session-scope"; import { BrowserPane } from "../browser-view"; import { BrowserHost, BROWSER_PLUGIN_ID } from "../browser-host"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; @@ -98,8 +99,6 @@ export type PluginServicesDependencies = { * detached `ui.panel` window is application-scoped — it does not belong to a * session — so it never receives them; plugin processes and docked views do. */ -const SESSION_SCOPED_EVENTS = new Set(["session:activated"]); - export function createPluginServices({ dataDir, logger, @@ -587,13 +586,12 @@ export function createPluginServices({ // ui.revealView) already enforces: a project-scoped plugin does not // receive a session its scope excludes (#1754). Sessions without a // project fall back to the workspace-relative check. - const scopeFilter = SESSION_SCOPED_EVENTS.has(event) - ? (pluginId: string) => - pluginActiveInProject( - pluginId, - (sessionId && sessionProjects.get(sessionId)) ?? null, - ) - : undefined; + const scopeFilter = sessionEventScopeFilter( + event, + sessionId, + sessionProjects, + pluginActiveInProject, + ); try { plugins.broadcastEvent(event, [payload], scopeFilter); } catch (error) { diff --git a/apps/desktop/test/plugin-services.test.mjs b/apps/desktop/test/plugin-services.test.mjs index 1a1a86256b..9a1ead6622 100644 --- a/apps/desktop/test/plugin-services.test.mjs +++ b/apps/desktop/test/plugin-services.test.mjs @@ -522,3 +522,41 @@ test("capability badges include every declared plugin capability", () => { "bus", ]); }); + +test("the session:activated scope filter derives from the announced session's project", async (t) => { + const { sessionEventScopeFilter, SESSION_SCOPED_EVENTS } = await import( + "../electron/main/plugin-session-scope.ts" + ); + const sessionProjects = new Map([["s-a", "/work/a"]]); + const activeInProject = (pluginId, projectPath) => + projectPath === "/work/a" && pluginId === "demo.in-scope"; + + // A selection event produces a filter that keys on the announced session's + // project: the in-scope plugin passes, another project's plugin does not. + const filter = sessionEventScopeFilter( + "session:activated", + "s-a", + sessionProjects, + activeInProject, + ); + assert.equal(typeof filter, "function"); + assert.equal(filter("demo.in-scope"), true); + assert.equal(filter("demo.out-of-scope"), false); + + // A session without a project checks against null, not against whatever + // the map happens to hold for another session. + const noProject = sessionEventScopeFilter( + "session:activated", + "s-global", + sessionProjects, + activeInProject, + ); + assert.equal(noProject("demo.in-scope"), false, "null project is passed through, not the map's value"); + + // Other session events fan out unconditionally. + assert.equal( + sessionEventScopeFilter("session:turnEnded", "s-a", sessionProjects, activeInProject), + undefined, + ); + assert.ok(SESSION_SCOPED_EVENTS.has("session:activated")); +}); diff --git a/apps/desktop/test/plugin-session-follow.test.mjs b/apps/desktop/test/plugin-session-follow.test.mjs index a6c5ae3388..39106521b0 100644 --- a/apps/desktop/test/plugin-session-follow.test.mjs +++ b/apps/desktop/test/plugin-session-follow.test.mjs @@ -381,17 +381,29 @@ test("broadcastEvent's filter keeps an out-of-scope plugin from receiving the se test("the session-push gate restarts with the renderer's boot id and the payload carries it", async () => { const { readMainSource, readStoreModule } = await import("./helpers/source-contracts.mjs"); - const ipcSource = await readMainSource("ipc/plugin-ui-ipc.ts"); + const { readFile } = await import("node:fs/promises"); + const gateSource = await readFile( + new URL("../electron/main/plugin-session-push-gate.ts", import.meta.url), + "utf8", + ); - // Main (#1753): a changed boot id resets the high-water mark, so pushes - // keep flowing after a renderer reload; within one boot the old - // out-of-order guard still applies. + // The gate module owns the boot-id reset (#1753): a new renderer boot + // restarts the high-water mark, and within a boot the out-of-order guard + // still ignores older sequences. assert.match( - ipcSource, - /const\s+bootId\s*=\s*typeof\s+payload\?\.bootId\s*===\s*"string"[\s\S]*?bootId\s*!==\s*activeSessionBootId[\s\S]*?activeSessionSeq\s*=\s*seq;[\s\S]*?return\s+\{\s*ok:\s*true,\s*ignored:\s*true\s*\};/, + gateSource, + /push\.bootId !== activeSessionBootId[\s\S]*?activeSessionSeq = push\.seq;[\s\S]*?return \{ ok: true, ignored: true \};/, "the gate must reset the high-water mark on a new renderer boot and keep the out-of-order guard within a boot", ); + // The production handler gates every push through the shared gate. + const ipcSource = await readMainSource("ipc/plugin-ui-ipc.ts"); + assert.match( + ipcSource, + /const sessionPushGate = createSessionPushGate\(\);[\s\S]*?sessionPushGate\(\{ seq, bootId \}\)\.ignored/, + "plugin-ui-ipc must route session pushes through the shared gate", + ); + // Renderer: the push carries the boot id the gate compares. const runtimeSource = await readStoreModule("../../src/features/app/useAppShellRuntime.tsx"); assert.match( @@ -405,3 +417,31 @@ test("the session-push gate restarts with the renderer's boot id and the payload "the push must carry the boot id", ); }); + +test("the session-push gate accepts a new renderer boot and still rejects an older sequence within it (#1753)", async (t) => { + const { createSessionPushGate } = await import( + "../electron/main/plugin-session-push-gate.ts" + ); + const gate = createSessionPushGate(); + + // Within one boot: in-order accepted, out-of-order ignored. + assert.deepEqual(gate({ seq: 3, bootId: "boot-1" }), { ok: true }); + assert.deepEqual(gate({ seq: 5, bootId: "boot-1" }), { ok: true }); + assert.deepEqual(gate({ seq: 4, bootId: "boot-1" }), { + ok: true, + ignored: true, + }); + + // A reload restarts the renderer counter: the new boot resets the mark and + // the low sequences flow again. + assert.deepEqual(gate({ seq: 1, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 2, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 1, bootId: "boot-2" }), { + ok: true, + ignored: true, + }); + + // Zero-sequence pushes (no selection) never touch the mark. + assert.deepEqual(gate({ seq: 0, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 2, bootId: "boot-2" }), { ok: true }); +}); diff --git a/apps/desktop/test/plugin-work-panel-views.test.mjs b/apps/desktop/test/plugin-work-panel-views.test.mjs index c802ac0f14..f659e15582 100644 --- a/apps/desktop/test/plugin-work-panel-views.test.mjs +++ b/apps/desktop/test/plugin-work-panel-views.test.mjs @@ -282,7 +282,7 @@ test("opening a different project refreshes the scope-filtered view list", () => }); test("host panel events reach docked views as well as detached windows", () => { - assert.match(viewHostSource, /broadcast\(event: string, payload: unknown\)/); + assert.match(viewHostSource, /broadcast\(event: string, payload: unknown(?:, filter\?:[^)]*)?\)/); assert.match(viewHostSource, /pi-plugin-panel-event:\$\{event\}/); assert.match(mainSource, /function broadcastPluginPanelEvent/); assert.match(mainSource, /broadcastPluginPanelEvent\("appearance:changed"/); From 19aaedf46e9d6e26994544f0fee7c53e945031fd Mon Sep 17 00:00:00 2001 From: vastsa Date: Tue, 20 Oct 2026 10:19:00 +0800 Subject: [PATCH 4/4] test(plugins): pin session filter and reload IPC wiring Exercise the actual session IPC handler across a renderer boot change and keep the stale-sequence guard observable. Pin the production fan-out so the same project filter is applied to plugin processes and docked views. --- .../test/main-wiring-contract.test.mjs | 27 +++++++++-- .../test/plugin-provider-catalog-ipc.test.mjs | 46 ++++++++++++++++++- .../test/plugin-work-panel-views.test.mjs | 1 + 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/desktop/test/main-wiring-contract.test.mjs b/apps/desktop/test/main-wiring-contract.test.mjs index 32991d669e..0b04a75444 100644 --- a/apps/desktop/test/main-wiring-contract.test.mjs +++ b/apps/desktop/test/main-wiring-contract.test.mjs @@ -204,9 +204,9 @@ test("a finished turn is announced to every plugin surface", () => { services, services.indexOf("{", services.indexOf("const broadcastSessionEvent")), ); - assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\](?:, scopeFilter)?\)/); + assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); assert.match(fanOut, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(fanOut, /pluginViews\.broadcast\(event, payload(?:, scopeFilter)?\)/); + assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); // Each surface owns its failure: one unreachable plugin process, panel or // view must not suppress the other two. assert.equal( @@ -228,9 +228,9 @@ test("the selected session is announced to every plugin surface", () => { // The snapshot is announced from the one module that owns it, and reaches the // same three surfaces as a finished turn. assert.match(context, /announce\("session:activated", next\)/); - assert.match(services, /plugins\.broadcastEvent\(event, \[payload\](?:, scopeFilter)?\)/); + assert.match(services, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); assert.match(services, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(services, /pluginViews\.broadcast\(event, payload(?:, scopeFilter)?\)/); + assert.match(services, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); // Turn identity and start time come from the host, never from the renderer. assert.match( services, @@ -242,6 +242,25 @@ test("the selected session is announced to every plugin surface", () => { assert.match(root, /pluginServices\.pluginSession\.noteTurnStarted\(sessionId, turnId\)/); }); +test("session activation derives one project filter for process and docked-view delivery", () => { + const services = read("services/plugin-services.ts"); + const fanOut = braceBody( + services, + services.indexOf("{", services.indexOf("const broadcastSessionEvent")), + ); + assert.match( + fanOut, + /sessionEventScopeFilter\(\s*event,\s*sessionId,\s*sessionProjects,\s*pluginActiveInProject,\s*\)/, + ); + assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); + assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); + assert.match( + fanOut, + /if \(!SESSION_SCOPED_EVENTS\.has\(event\)\) \{[\s\S]*?pluginPanels\.broadcast\(event, payload\)/, + "selection events stay out of detached app-scoped panels", + ); +}); + test("the shell publishes its selection and opens a revealed view", () => { const shell = readFileSync( join(here, "..", "src", "features", "app", "useAppShellRuntime.tsx"), diff --git a/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs b/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs index df8a2363e9..b61f6d5fff 100644 --- a/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs +++ b/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs @@ -4,9 +4,10 @@ import test from "node:test"; import ts from "typescript"; import { IPC } from "../../../packages/shared/src/protocol.ts"; import { pluginProviderCatalogEntries } from "../electron/main/plugin-provider-catalog.ts"; +import { createSessionPushGate } from "../electron/main/plugin-session-push-gate.ts"; /** Load the real main-process registration function with unrelated hosts stubbed. */ -function loadPluginUiIpc() { +function loadPluginUiIpc({ gateFactory = () => () => ({ ignored: false }) } = {}) { const file = new URL("../electron/main/ipc/plugin-ui-ipc.ts", import.meta.url); const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, @@ -28,7 +29,7 @@ function loadPluginUiIpc() { "../plugin-view-host": { pluginViewKey: (...parts) => parts.join("/") }, "../plugin-panel-host": {}, "../plugin-provider-catalog": { pluginProviderCatalogEntries }, - "../plugin-session-push-gate": { createSessionPushGate: () => () => ({ ignored: false }) }, + "../plugin-session-push-gate": { createSessionPushGate: gateFactory }, }; new Function("require", "exports", "module", outputText)( (id) => { @@ -91,3 +92,44 @@ test("the plugin Add Service IPC returns declared categories through the Host ha description: "一句话中文简介。", }]); }); + +test("the plugin session IPC resets ordering on renderer reload and rejects stale pushes", async () => { + const handlers = new Map(); + const activeSessions = []; + loadPluginUiIpc({ gateFactory: createSessionPushGate })({ + registrar: { handle: (channel, handler) => handlers.set(channel, handler) }, + plugins: { listLoaded: () => [] }, + browserHost: {}, + pluginViews: {}, + pluginPanels: {}, + pluginActiveInProject: () => true, + currentWorkspacePath: () => null, + getUpdaterLocale: () => "en", + getPluginPanelTheme: () => ({}), + setActivePluginSession: (session) => activeSessions.push(session), + }); + + const handler = handlers.get(IPC.invoke.pluginSessionActive); + assert.equal(typeof handler, "function", "session-active IPC handler is registered"); + assert.deepEqual( + await handler({ sessionId: "before-reload", projectPath: "/work/a", seq: 8, bootId: "boot-a" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "after-reload", projectPath: "/work/b", seq: 1, bootId: "boot-b" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "after-newer-push", projectPath: "/work/b", seq: 3, bootId: "boot-b" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "stale", projectPath: "/work/a", seq: 2, bootId: "boot-b" }), + { ok: true, ignored: true }, + ); + assert.deepEqual(activeSessions, [ + { sessionId: "before-reload", projectPath: "/work/a" }, + { sessionId: "after-reload", projectPath: "/work/b" }, + { sessionId: "after-newer-push", projectPath: "/work/b" }, + ]); +}); diff --git a/apps/desktop/test/plugin-work-panel-views.test.mjs b/apps/desktop/test/plugin-work-panel-views.test.mjs index f659e15582..c7ed51eaa4 100644 --- a/apps/desktop/test/plugin-work-panel-views.test.mjs +++ b/apps/desktop/test/plugin-work-panel-views.test.mjs @@ -283,6 +283,7 @@ test("opening a different project refreshes the scope-filtered view list", () => test("host panel events reach docked views as well as detached windows", () => { assert.match(viewHostSource, /broadcast\(event: string, payload: unknown(?:, filter\?:[^)]*)?\)/); + assert.match(viewHostSource, /if \(filter && !filter\(entry\.pluginId\)\) continue/); assert.match(viewHostSource, /pi-plugin-panel-event:\$\{event\}/); assert.match(mainSource, /function broadcastPluginPanelEvent/); assert.match(mainSource, /broadcastPluginPanelEvent\("appearance:changed"/);