Dominik Jain 036f59266b 🐛 Discard cancelled plugin loads after unloading
Cause:
- Disabling MCP during script loading could leave a late plugin instance
  attached to a stopped controller, blocking connection after re-enabling.

Fix:
- Track pending loads and discard instances cancelled by unload.
- Prevent duplicate global loads and preserve newer replacement instances.
- Clear pending state after failures so a subsequent load can retry.

Validation:
- Pass 84 runtime tests, the runtime build, lint and formatting checks.
- Reproduce the race with a delayed script in the browser, then verify
  re-enabling restores the session ID, themed indicator and MCP execution.

AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
2026-10-07 14:58:32 +02:00

140 lines
4.0 KiB
TypeScript

import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
const pendingPlugins = new Map<Manifest['pluginId'], symbol>();
export type ContextBuilder = (id: string) => Context;
let contextBuilder: ContextBuilder | null = null;
export function setContextBuilder(builder: ContextBuilder) {
contextBuilder = builder;
}
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
// Background and global plugins keep running, so they must stay registered:
// the registry routes their UI messages and lets them be unloaded later.
plugins = plugins.filter((pluginApi) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
if (
pluginApi.manifest?.scope === 'global' ||
(pluginApi.manifest as any)?.allowBackground
) {
return true;
}
pluginApi.plugin.close();
return false;
});
};
window.addEventListener('message', (event) => {
try {
const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);
if (senderPlugin) {
senderPlugin.plugin.sendMessage(event.data);
}
} catch (err) {
console.error(err);
}
});
export const loadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
const loadId = Symbol();
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
if (!context) {
return;
}
if (manifest.scope === 'global') {
if (
pendingPlugins.has(manifest.pluginId) ||
plugins.some(
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
)
) {
return;
}
} else {
closeAllPlugins();
}
// The host context is not deeply frozen at this load stage.
//
// The context still contains host-internal function objects and shared
// prototypes that the host may legitimately extend after plugin load
// (for example, by assigning custom properties). Deep-freezing here
// would freeze those prototypes before SES override taming completes,
// preventing later host-side mutations with a "Cannot assign to read
// only property" TypeError.
//
// Responsibility boundary: this function forwards the context to the
// sandbox layer without deep-freezing it. The public API that plugins
// consume is constructed by the API module (`api/index.ts`), and
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
pendingPlugins.set(manifest.pluginId, loadId);
let plugin: Awaited<ReturnType<typeof createPlugin>> | undefined =
undefined;
plugin = await createPlugin(
context,
manifest,
() => {
plugins = plugins.filter((api) => api !== plugin);
if (closeCallback) {
closeCallback();
}
},
apiExtensions,
);
if (pendingPlugins.get(manifest.pluginId) !== loadId) {
plugin.plugin.close();
return;
}
plugins.push(plugin);
} catch (error) {
if (manifest.scope !== 'global') closeAllPlugins();
throw error;
} finally {
if (pendingPlugins.get(manifest.pluginId) === loadId) {
pendingPlugins.delete(manifest.pluginId);
}
}
};
export const ɵloadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
await loadPlugin(manifest, closeCallback, apiExtensions);
};
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
const manifest = await loadManifest(manifestUrl);
await ɵloadPlugin(manifest);
};
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {
pendingPlugins.delete(id);
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
if (plugin) {
plugin.plugin.close();
}
};