🐛 Fix race condition between MCP initialization and plugin runtime (#10137)

* 🐛 Fix race condition between MCP init and plugin runtime

Add promise-based synchronization to ensure MCP initialization waits
for plugin runtime to be ready before calling global.ɵloadPlugin.

- Add runtime-ready-promise in app.plugins that resolves when
  init-plugins-runtime completes
- Add wait-for-runtime function for other modules to await readiness
- MCP init now waits for runtime via rx/from before starting plugin
- Add defensive guards in start-plugin!, load-plugin!, close-plugin!
  to check if plugin APIs exist before calling
- Rename init-plugins-runtime! to init-plugins-runtime

Fixes: global.ɵloadPlugin is not a function error when MCP plugin
starts before async plugin runtime initialization completes.

* 📎 Add 'create-pr' opencode skill
This commit is contained in:
Andrey Antukh 2026-06-12 11:40:02 +02:00 committed by GitHub
parent 7cb7f7adb2
commit a8d0c18c1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 196 additions and 74 deletions

View File

@ -0,0 +1,81 @@
---
name: create-pr
description: Create a GitHub PR following Penpot conventions, with a concise engineer-focused description
---
# Create Pull Request
Create a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
## When to Use
- Opening a new pull request
- The user asks to create a PR
- Code changes are ready and committed
## Workflow
### 1. Verify Prerequisites
```bash
git branch --show-current
git log --oneline main..HEAD
```
### 2. Check if Branch is Pushed
```bash
BRANCH=$(git branch --show-current)
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
echo "Branch is pushed, proceeding with PR creation"
else
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
exit 1
fi
```
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
### 3. Create PR Body
Write to `/tmp/pr-body.md` to avoid shell quoting issues:
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
```
### 4. Create the PR
Follow title and description format from `mem:workflow/creating-prs` and `mem:workflow/creating-commits`.
```bash
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
```
### 5. What NOT to Include
- ❌ List of files changed (visible in diff)
- ❌ Testing steps (CI handles this)
- ❌ Screenshots unless UI-visible
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
## Key Principles
- **Write for humans.** The diff shows what changed. The description explains why.
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
- **Skip the obvious.** Don't explain what `git diff` already shows.

View File

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb]
[app.common.logging :as log]
[app.common.time :as ct]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
@ -57,45 +58,57 @@
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "start-plugin!"))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}]
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(do
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "load-plugin!")
(st/emit! (remove-current-plugin plugin-id))))))
(defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?]
@ -135,10 +148,15 @@
(defn close-plugin!
[{:keys [plugin-id]}]
(try
(.ɵunloadPlugin ^js ug/global plugin-id)
(catch :default e
(.error js/console "Error" e))))
(let [unload-plugin (unchecked-get ug/global "ɵunloadPlugin")]
(if (fn? unload-plugin)
(try
(unload-plugin plugin-id)
(catch :default e
(.error js/console "Error" e)))
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "close-plugin!"))))
(defn close-current-plugin
[& {:keys [close-only-edition-plugins?]}]

View File

@ -15,7 +15,7 @@
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.register :refer [mcp-plugin-id]]
[app.plugins.register :as preg]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
@ -29,7 +29,7 @@
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id mcp-plugin-id
:plugin-id preg/mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
@ -195,41 +195,45 @@
(defn init-mcp
[stream]
(->> (rp/cmd! :get-current-mcp-token)
(rx/tap
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; Wait for plugins runtime to be initialized before starting the MCP plugin.
;; This ensures global.ɵloadPlugin is available when start-plugin! is called.
(->> (rx/from (preg/wait-for-runtime))
(rx/mapcat
(fn [_]
(->> (rp/cmd! :get-current-mcp-token)
(rx/tap
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
(when (= status "connected")
(start-reconnect-watcher!))
(st/emit! (update-mcp-connection-status status))
(log/info :hint "MCP STATUS" :status status))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
(when (= status "connected")
(start-reconnect-watcher!))
(st/emit! (update-mcp-connection-status status))
(log/info :hint "MCP STATUS" :status status))
:on
(fn [event cb]
(when-let [event
(case event
"disconnect" ::disconnect
"connect" ::connect
nil)]
:on
(fn [event cb]
(when-let [event
(case event
"disconnect" ::disconnect
"connect" ::connect
nil)]
(let [stopper (rx/filter finalize-workspace? stream)]
(->> stream
(rx/filter (ptk/type? event))
(rx/take-until stopper)
(rx/subs! #(cb))))))}}))))
(rx/ignore)))
(let [stopper (rx/filter finalize-workspace? stream)]
(->> stream
(rx/filter (ptk/type? event))
(rx/take-until stopper)
(rx/subs! #(cb))))))}})))))))))
(defn init
[]

View File

@ -17,14 +17,17 @@
[app.plugins.grid :as grid]
[app.plugins.library :as library]
[app.plugins.public-utils]
[app.plugins.register :as preg]
[app.plugins.ruler-guides :as rg]
[app.plugins.shape :as shape]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn init-plugins-runtime!
(defn init-plugins-runtime
[]
(runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id))))
(runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id)))
;; Signal that runtime is ready
(preg/signal-runtime-ready))
(defn initialize
[]
@ -38,7 +41,7 @@
(rx/observe-on :async)
(rx/filter #(features/active-feature? @st/state "plugins/runtime"))
(rx/take 1)
(rx/tap init-plugins-runtime!)
(rx/tap init-plugins-runtime)
(rx/ignore)))))
;; Prevent circular dependency

View File

@ -15,12 +15,28 @@
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.object :as obj]
[beicon.v2.core :as rx]))
[beicon.v2.core :as rx]
[promesa.core :as p]))
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
;; cause a circular dependency
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
;; Promise that resolves when plugins runtime is initialized.
;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace
(defonce ^:private runtime-ready-promise (p/deferred))
(defn wait-for-runtime
"Returns a promise that resolves when plugins runtime is initialized."
[]
runtime-ready-promise)
(defn signal-runtime-ready
"Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime."
[]
(when (p/pending? runtime-ready-promise)
(p/resolve! runtime-ready-promise true)))
;; Stores the installed plugins information
(defonce ^:private registry (atom {}))

View File

@ -24,5 +24,5 @@
(defn ^:export plugins []
(st/emit! (features/enable-feature "plugins/runtime"))
(plugins/init-plugins-runtime!)
(plugins/init-plugins-runtime)
nil)