🐛 Handle plugin errors gracefully without crashing the UI (#8810)

* 🐛 Handle plugin errors gracefully without crashing the UI

Plugin errors (like 'Set is not a constructor') were propagating to the
global error handler and showing the exception page. This fix:

- Uses a WeakMap to track plugin errors (works in SES hardened environment)
- Wraps setTimeout/setInterval handlers to mark errors and re-throw them
- Frontend global handler checks isPluginError and logs to console

Plugin errors are now logged to console with 'Plugin Error' prefix but
don't crash the main application or show the exception page.

Signed-off-by: AI Agent <agent@penpot.app>

*  Improved handling of plugin errors on initialization

*  Fix test and linter

---------

Signed-off-by: AI Agent <agent@penpot.app>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
Andrey Antukh 2026-04-01 11:37:27 +02:00 committed by GitHub
parent 8fcbfadd49
commit f7e1bcf87f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 215 additions and 69 deletions

View File

@ -7,12 +7,14 @@
(ns app.main.data.plugins (ns app.main.data.plugins
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb] [app.common.files.changes-builder :as pcb]
[app.common.time :as ct] [app.common.time :as ct]
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.flags :as pflag] [app.plugins.flags :as pflag]
[app.plugins.register :as preg] [app.plugins.register :as preg]
@ -20,7 +22,8 @@
[app.util.http :as http] [app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]
[promesa.core :as p]))
(defn save-plugin-permissions-peek (defn save-plugin-permissions-peek
[id permissions] [id permissions]
@ -54,40 +57,45 @@
(defn start-plugin! (defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(.ɵloadPlugin (-> (.ɵloadPlugin
^js ug/global ^js ug/global
#js {:pluginId plugin-id #js {:pluginId plugin-id
:name name :name name
:version version :version version
:description description :description description
:host host :host host
:code code :code code
:allowBackground (boolean allow-background) :allowBackground (boolean allow-background)
:permissions (apply array permissions)} :permissions (apply array permissions)}
nil nil
extensions)) extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn- load-plugin! (defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}] [{:keys [plugin-id name version description host code icon permissions]}]
(try (st/emit! (pflag/clear plugin-id)
(st/emit! (pflag/clear plugin-id) (save-current-plugin plugin-id))
(save-current-plugin plugin-id))
(.ɵloadPlugin ^js ug/global (-> (.ɵloadPlugin
#js {:pluginId plugin-id ^js ug/global
:name name #js {:pluginId plugin-id
:description description :name name
:version version :description description
:host host :version version
:code code :host host
:icon icon :code code
:permissions (apply array permissions)} :icon icon
(fn [] :permissions (apply array permissions)}
(st/emit! (remove-current-plugin plugin-id)))) (fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e (p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id)) (st/emit! (remove-current-plugin plugin-id))
(.error js/console "Error" e)))) (ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn open-plugin! (defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?] [{:keys [url] :as manifest} user-can-edit?]

View File

@ -33,6 +33,16 @@
;; Will contain last uncaught exception ;; Will contain last uncaught exception
(def last-exception nil) (def last-exception nil)
(defn is-plugin-error?
"This is a placeholder that always return false. It will be
overwritten when plugin system is initialized. This works this way
because we can't import plugins here because plugins requries full
DOM.
This placeholder is set on app.plugins/initialize event"
[_]
false)
;; --- Stale-asset error detection and auto-reload ;; --- Stale-asset error detection and auto-reload
;; ;;
;; When the browser loads JS modules from different builds (e.g. shared.js from ;; When the browser loads JS modules from different builds (e.g. shared.js from
@ -387,6 +397,15 @@
(and (string? stack) (and (string? stack)
(str/includes? stack "posthog")))) (str/includes? stack "posthog"))))
;; Check if the error is marked as originating from plugin code.
;; The plugin runtime tracks plugin errors in a WeakMap, which works
;; even in SES hardened environments where error objects may be frozen.
(from-plugin? [cause]
(try
(is-plugin-error? cause)
(catch :default _
false)))
(is-ignorable-exception? [cause] (is-ignorable-exception? [cause]
(let [message (ex-message cause)] (let [message (ex-message cause)]
(or (from-extension? cause) (or (from-extension? cause)
@ -405,32 +424,56 @@
(on-unhandled-error [event] (on-unhandled-error [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")] (when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause) (cond
(if (stale-asset-error? cause) (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause)) (cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)] ;; Plugin errors: log to console and ignore
(set! last-exception cause) (from-plugin? cause)
(if (= :wasm-error type) (ex/print-throwable cause :prefix "Plugin Error")
(on-error cause)
(do ;; Other ignorable exceptions: ignore silently
(ex/print-throwable cause :prefix "Uncaught Exception") (is-ignorable-exception? cause)
(ts/asap #(flash :cause cause :type :unhandled))))))))) nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled))))))))
(on-unhandled-rejection [event] (on-unhandled-rejection [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")] (when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause) (cond
(if (stale-asset-error? cause) (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause)) (cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)] ;; Plugin errors: log to console and ignore
(set! last-exception cause) (from-plugin? cause)
(if (= :wasm-error type) (ex/print-throwable cause :prefix "Plugin Error")
(on-error cause)
(do ;; Other ignorable exceptions: ignore silently
(ex/print-throwable cause :prefix "Uncaught Rejection") (is-ignorable-exception? cause)
(ts/asap #(flash :cause cause :type :unhandled)))))))))] nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled))))))))]
(.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -8,6 +8,7 @@
"RPC for plugins runtime." "RPC for plugins runtime."
(:require (:require
["@penpot/plugins-runtime" :as runtime] ["@penpot/plugins-runtime" :as runtime]
[app.main.errors :as errors]
[app.main.features :as features] [app.main.features :as features]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.api :as api] [app.plugins.api :as api]
@ -30,6 +31,8 @@
(ptk/reify ::initialize (ptk/reify ::initialize
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ stream]
(set! errors/is-plugin-error? runtime/isPluginError)
(->> stream (->> stream
(rx/filter (ptk/type? ::features/initialize)) (rx/filter (ptk/type? ::features/initialize))
(rx/observe-on :async) (rx/observe-on :async)

View File

@ -393,7 +393,7 @@ export class AppComponent {
} }
#startDownload(name: string, data: Uint8Array) { #startDownload(name: string, data: Uint8Array) {
const blob = new Blob([data], { type: 'application/octet-stream' }); const blob = new Blob([data as any], { type: 'application/octet-stream' });
// We need to start a download with this URL // We need to start a download with this URL
const downloadURL = URL.createObjectURL(blob); const downloadURL = URL.createObjectURL(blob);

View File

@ -8,6 +8,9 @@ import {
ɵunloadPlugin, ɵunloadPlugin,
} from './lib/load-plugin.js'; } from './lib/load-plugin.js';
// Export the plugin error checker so the frontend can identify plugin errors
export { isPluginError } from './lib/create-sandbox.js';
import type { Context } from '@penpot/plugin-types'; import type { Context } from '@penpot/plugin-types';
console.log('%c[PLUGINS] Loading plugin system', 'color: #008d7c'); console.log('%c[PLUGINS] Loading plugin system', 'color: #008d7c');

View File

@ -11,6 +11,7 @@ vi.mock('./plugin-manager.js', () => ({
vi.mock('./create-sandbox.js', () => ({ vi.mock('./create-sandbox.js', () => ({
createSandbox: vi.fn(), createSandbox: vi.fn(),
markPluginError: vi.fn(),
})); }));
describe('createPlugin', () => { describe('createPlugin', () => {
@ -116,7 +117,11 @@ describe('createPlugin', () => {
throw new Error('Evaluation error'); throw new Error('Evaluation error');
}); });
await createPlugin(mockContext, manifest, onCloseCallback); try {
await createPlugin(mockContext, manifest, onCloseCallback);
} catch (err) {
expect.assert(err);
}
expect(mockPluginManager.close).toHaveBeenCalled(); expect(mockPluginManager.close).toHaveBeenCalled();
}); });

View File

@ -1,7 +1,7 @@
import type { Context } from '@penpot/plugin-types'; import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js'; import type { Manifest } from './models/manifest.model.js';
import { createPluginManager } from './plugin-manager.js'; import { createPluginManager } from './plugin-manager.js';
import { createSandbox } from './create-sandbox.js'; import { createSandbox, markPluginError } from './create-sandbox.js';
export async function createPlugin( export async function createPlugin(
context: Context, context: Context,
@ -13,9 +13,9 @@ export async function createPlugin(
try { try {
sandbox.evaluate(); sandbox.evaluate();
} catch (error) { } catch (error) {
console.error(error); markPluginError(error);
plugin.close(); plugin.close();
throw error;
} }
}; };
@ -33,7 +33,7 @@ export async function createPlugin(
const sandbox = createSandbox(plugin, apiExtensions); const sandbox = createSandbox(plugin, apiExtensions);
evaluateSandbox(); await evaluateSandbox();
return { return {
plugin, plugin,

View File

@ -3,6 +3,61 @@ import type { createPluginManager } from './plugin-manager';
import { createApi } from './api'; import { createApi } from './api';
import { ses } from './ses.js'; import { ses } from './ses.js';
/**
* WeakMap used to track errors originating from plugin code.
* Using a WeakMap is safer than extending error objects because:
* 1. It works even if the error object is frozen (SES hardened environment)
* 2. It doesn't require modifying the error object
* 3. It allows garbage collection of error objects when no longer referenced
*/
const pluginErrors = new WeakMap<object, true>();
/**
* Checks if an error originated from plugin code.
*/
export function isPluginError(error: unknown): boolean {
if (error !== null && typeof error === 'object') {
return pluginErrors.has(error as object);
}
return false;
}
/**
* Marks an error as originating from plugin code.
* Uses a WeakMap so it works even if the error object is frozen.
*/
export function markPluginError(error: unknown): void {
if (error !== null && typeof error === 'object') {
pluginErrors.set(error as object, true);
}
}
/**
* Wraps a handler function to mark any thrown errors as plugin errors.
* Errors are marked and re-thrown so they propagate to the global error handler,
* where they can be identified and handled appropriately.
*/
function wrapHandler<T extends (...args: unknown[]) => unknown>(
handler: T,
): (...args: Parameters<T>) => ReturnType<T> {
return function (...args: Parameters<T>) {
try {
const result = handler(...args);
// Handle async functions - mark errors in the returned promise
if (result instanceof Promise) {
return result.catch((error: unknown) => {
markPluginError(error);
throw error;
}) as ReturnType<T>;
}
return result as ReturnType<T>;
} catch (error) {
markPluginError(error);
throw error;
}
};
}
export function createSandbox( export function createSandbox(
plugin: Awaited<ReturnType<typeof createPluginManager>>, plugin: Awaited<ReturnType<typeof createPluginManager>>,
apiExtensions?: object, apiExtensions?: object,
@ -58,9 +113,10 @@ export function createSandbox(
fetch: ses.harden(safeFetch), fetch: ses.harden(safeFetch),
setTimeout: ses.harden( setTimeout: ses.harden(
(...[handler, timeout]: Parameters<typeof setTimeout>) => { (...[handler, timeout]: Parameters<typeof setTimeout>) => {
const timeoutId = setTimeout(() => { const wrappedHandler = wrapHandler(
handler(); typeof handler === 'function' ? handler : () => {},
}, timeout); );
const timeoutId = setTimeout(wrappedHandler, timeout);
plugin.timeouts.add(timeoutId); plugin.timeouts.add(timeoutId);
@ -72,6 +128,23 @@ export function createSandbox(
plugin.timeouts.delete(id); plugin.timeouts.delete(id);
}), }),
setInterval: ses.harden(
(...[handler, interval]: Parameters<typeof setInterval>) => {
const wrappedHandler = wrapHandler(
typeof handler === 'function' ? handler : () => {},
);
const intervalId = setInterval(wrappedHandler, interval);
plugin.intervals.add(intervalId);
return ses.safeReturn(intervalId);
},
) as typeof setInterval,
clearInterval: ses.harden((id: ReturnType<typeof setInterval>) => {
clearInterval(id);
plugin.intervals.delete(id);
}),
/** /**
* GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS * GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS

View File

@ -19,6 +19,10 @@ vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(), createPlugin: vi.fn(),
})); }));
vi.mock('./create-sandbox.js', () => ({
markPluginError: vi.fn(),
}));
vi.mock('./ses.js', () => ({ vi.mock('./ses.js', () => ({
ses: { ses: {
harden: vi.fn().mockImplementation((obj) => obj), harden: vi.fn().mockImplementation((obj) => obj),
@ -102,16 +106,17 @@ describe('plugin-loader', () => {
}); });
it('should handle errors and close all plugins', async () => { it('should handle errors and close all plugins', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(createPlugin).mockRejectedValue( vi.mocked(createPlugin).mockRejectedValue(
new Error('Plugin creation failed'), new Error('Plugin creation failed'),
); );
await loadPlugin(manifest); try {
await loadPlugin(manifest);
} catch (err) {
expect.assert(err);
}
expect(getPlugins()).toHaveLength(0); expect(getPlugins()).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalled();
}); });
it('should handle messages sent to plugins', async () => { it('should handle messages sent to plugins', async () => {

View File

@ -64,11 +64,10 @@ export const loadPlugin = async function (
}, },
apiExtensions, apiExtensions,
); );
plugins.push(plugin); plugins.push(plugin);
} catch (error) { } catch (error) {
closeAllPlugins(); closeAllPlugins();
console.error(error); throw error;
} }
}; };
@ -77,12 +76,12 @@ export const ɵloadPlugin = async function (
closeCallback?: () => void, closeCallback?: () => void,
apiExtensions?: object, apiExtensions?: object,
) { ) {
loadPlugin(manifest, closeCallback, apiExtensions); await loadPlugin(manifest, closeCallback, apiExtensions);
}; };
export const ɵloadPluginByUrl = async function (manifestUrl: string) { export const ɵloadPluginByUrl = async function (manifestUrl: string) {
const manifest = await loadManifest(manifestUrl); const manifest = await loadManifest(manifestUrl);
ɵloadPlugin(manifest); await ɵloadPlugin(manifest);
}; };
export const ɵunloadPlugin = function (id: Manifest['pluginId']) { export const ɵunloadPlugin = function (id: Manifest['pluginId']) {

View File

@ -21,6 +21,7 @@ export async function createPluginManager(
let modal: PluginModalElement | null = null; let modal: PluginModalElement | null = null;
let uiMessagesCallbacks: ((message: unknown) => void)[] = []; let uiMessagesCallbacks: ((message: unknown) => void)[] = [];
const timeouts = new Set<ReturnType<typeof setTimeout>>(); const timeouts = new Set<ReturnType<typeof setTimeout>>();
const intervals = new Set<ReturnType<typeof setInterval>>();
const allowDownloads = !!manifest.permissions.find( const allowDownloads = !!manifest.permissions.find(
(s) => s === 'allow:downloads', (s) => s === 'allow:downloads',
@ -55,6 +56,9 @@ export async function createPluginManager(
timeouts.forEach(clearTimeout); timeouts.forEach(clearTimeout);
timeouts.clear(); timeouts.clear();
intervals.forEach(clearInterval);
intervals.clear();
if (modal) { if (modal) {
modal.removeEventListener('close', closePlugin); modal.removeEventListener('close', closePlugin);
modal.remove(); modal.remove();
@ -151,6 +155,9 @@ export async function createPluginManager(
get timeouts() { get timeouts() {
return timeouts; return timeouts;
}, },
get intervals() {
return intervals;
},
get code() { get code() {
return code; return code;
}, },