✨ Keep Penpot tabs connected to MCP from being frozen

Chrome freezes some background tabs, for instance busy ones while
Battery Saver is on, and a frozen page cannot run the MCP plugin.
Chrome does not freeze a page that holds a Web Lock, so the page holds
a lock named penpot-mcp-session from the moment the user asks to
connect until the connection stops: on disconnect, logout, MCP being
turned off or the MCP runtime starting again. The lock is shared, so
several tabs can hold it at once. Browsers without Web Locks skip it,
and when the lock request fails, the next connection request tries
again.

Firefox does not freeze background pages, so the lock changes nothing
there.
This commit is contained in:
alonso.torres 2026-10-30 09:11:49 +00:00
parent 98a0163d25
commit 9af694e0b6
2 changed files with 76 additions and 1 deletions

View File

@ -45,6 +45,32 @@
(defonce interval-sub
(atom nil))
;; Resolves the promise that keeps the session Web Lock held; nil when none is.
(defonce ^:private session-lock-release
(atom nil))
(defn- hold-session-lock
"Holds a shared Web Lock while the MCP connection is requested. Chrome
does not freeze a page that holds a Web Lock, and a frozen page cannot
run the plugin."
[]
(when-let [locks (and (nil? @session-lock-release)
(unchecked-get globals/navigator "locks"))]
(let [released (js/Promise. (fn [resolve] (reset! session-lock-release resolve)))
release @session-lock-release]
(-> (.request locks "penpot-mcp-session" #js {:mode "shared"} (constantly released))
(.catch (fn [cause]
;; lets a later connection request try again, unless a
;; newer lock request has already replaced this one
(compare-and-set! session-lock-release release nil)
(log/warn :hint "unable to hold the MCP session lock" :cause cause)))))))
(defn- release-session-lock
[]
(when-let [release @session-lock-release]
(reset! session-lock-release nil)
(release)))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
@ -61,7 +87,12 @@
(watch [_ state _]
(if (get-in state [:mcp :connection-requested])
(rx/of (ptk/data-event ::connect))
(rx/empty)))))
(rx/empty)))
ptk/EffectEvent
(effect [_ state _]
(when (get-in state [:mcp :connection-requested])
(hold-session-lock)))))
(defn- start-reconnect-watcher
[]
@ -134,6 +165,7 @@
ptk/EffectEvent
(effect [_ _ _]
(release-session-lock)
(stop-reconnect-watcher!))))
(defn- extension-hello?
@ -231,6 +263,7 @@
(.removeEventListener globals/window "message" on-message)
(when @announce?
(announce-session "disconnected" nil))
(release-session-lock)
(stop-reconnect-watcher!)
(dp/close-plugin! default-manifest))))
(.addEventListener globals/window "message" on-message)

View File

@ -13,6 +13,7 @@
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[app.plugins.register :as preg]
[app.util.globals :as globals]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
@ -183,6 +184,47 @@
:on-next #(swap! events conj (ptk/type %))))
(t/is (= [:app.main.data.workspace.mcp/connect] @events))))
(defn- connect
"Runs the effect of a connection request made with the given MCP state."
[mcp-state]
(let [event (mcp/connect-mcp)]
(ptk/effect event (ptk/update event {:mcp mcp-state}) nil)))
(t/deftest ^:async test-session-lock-follows-connection-request
(let [requests (atom [])
released (atom 0)
locks #js {:request (fn [lock-name options callback]
(swap! requests conj [lock-name (.-mode options)])
(.then (callback nil) #(swap! released inc)))}]
(await
(mock/with-mocks*
{globals/navigator #js {:locks locks}}
(connect {:enabled false})
(t/is (empty? @requests))
(connect {:enabled true :token-valid true})
(connect {:enabled true :token-valid true})
(t/is (= [["penpot-mcp-session" "shared"]] @requests))
(ptk/effect (mcp/user-disconnect-mcp) nil nil)
(await (a/settle))
(t/is (= 1 @released))
(ptk/effect (mcp/user-disconnect-mcp) nil nil)
(await (a/settle))
(t/is (= 1 @released))))))
(t/deftest ^:async test-session-lock-retried-after-failed-request
(let [requests (atom 0)
locks #js {:request (fn [_ _ _]
(swap! requests inc)
(js/Promise.reject (js/Error. "lock request denied")))}]
(await
(mock/with-mocks*
{globals/navigator #js {:locks locks}}
(connect {:enabled true :token-valid true})
(await (a/settle))
(connect {:enabled true :token-valid true})
(await (a/settle))
(t/is (= 2 @requests))))))
(t/deftest test-disconnect-clears-connection-intent
(let [state {:mcp {:connection-requested true
:connection-status "connecting"