Keep panel loaders across plugin changes so the OSD loads once (#13439)

Every plugin change handed the panel Instantiator a fresh array, which
rebuilds every panel. The panels were rebuilt four times in the first
700ms of startup, each rebuild starting a new asynchronous load of every
keepLoaded panel while the last was still in flight. Under load two OSDs
could run at once, each registering the osd IPC handler: the runtime
smoke test's intermittent "another handler is registered for target
osd".

The entries now live in a ListModel synced in place. A panel whose
plugin still loads the same way keeps its loader, so startup adds each
panel once, and a full plugin reload still clears the model to rebuild
from fresh code.

Under a six-core busy load the smoke test hit the duplicate in 1 of 5
runs on quattro and in none of 10 with this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 21:29:06 +02:00
1 parent 5929a9b80c
commit 0066ea216b
2 files changed
+77 -11

No files matched your search

+47 -11
View File
@@ -1235,7 +1235,7 @@ ShellRoot {
function unloadPanels() {
for (var id in panelLoaders) hide(id)
panelEntries = []
panelEntryModel.clear()
panelLoaders = ({})
pendingPayloads = ({})
openPanelIds = ({})
@@ -1284,7 +1284,43 @@ ShellRoot {
// One Loader per discoverable panel/overlay/menu plugin. Active when the
// host marks it open. The Loader holds onto the instance while active so the
// plugin's FloatingWindow + state survive between summons within a session.
property var panelEntries: []
//
// The entries live in a ListModel synced in place. Handing the Instantiator
// a fresh array instead rebuilt every panel on every plugin change, four
// times over during startup: each rebuild started a new asynchronous load of
// every keepLoaded panel while the last was in flight, so two OSDs could run
// at once, each registering its IPC handler. A full plugin reload clears
// the model in unloadPanels, so panels still rebuild from fresh code then.
ListModel { id: panelEntryModel }
function syncPanelEntries() {
var wanted = ({})
var order = []
var entries = computePanelEntries()
for (var i = 0; i < entries.length; i++) {
var entry = entries[i]
entry.sourceUrl = String(shell.pluginRegistry.entryPointUrl(entry.manifest, entry.kind) || "")
wanted[entry.id] = entry
order.push(entry.id)
}
// Keep an entry whose plugin still loads the same way; drop the rest.
for (var j = panelEntryModel.count - 1; j >= 0; j--) {
var row = panelEntryModel.get(j)
var next = wanted[row.pluginId]
if (next && next.kind === row.entryKind && next.keepLoaded === row.keepLoaded && next.sourceUrl === row.sourceUrl) {
delete wanted[row.pluginId]
} else {
panelEntryModel.remove(j)
}
}
for (var k = 0; k < order.length; k++) {
var added = wanted[order[k]]
if (!added) continue
panelEntryModel.append({ pluginId: added.id, entryKind: added.kind, keepLoaded: added.keepLoaded, sourceUrl: added.sourceUrl })
}
}
function computePanelEntries() {
var out = []
@@ -1307,21 +1343,21 @@ ShellRoot {
Connections {
target: shell.pluginRegistry
function onPluginsChanged() { if (!shell.pluginReloading) shell.panelEntries = shell.computePanelEntries() }
function onPluginsChanged() { if (!shell.pluginReloading) shell.syncPanelEntries() }
}
Instantiator {
model: shell.panelEntries
model: panelEntryModel
active: true
delegate: QtObject {
id: panelEntry
required property var modelData
readonly property string pluginId: modelData.id
readonly property var manifest: modelData.manifest
readonly property string entryKind: modelData.kind
readonly property bool keepLoaded: modelData.keepLoaded === true
readonly property string sourceUrl: shell.pluginRegistry.entryPointUrl(manifest, entryKind)
required property string pluginId
required property string entryKind
required property bool keepLoaded
required property string sourceUrl
// The manifest can be replaced on a rescan without the entry changing.
readonly property var manifest: shell.pluginRegistry.installedPlugins[pluginId]
property Loader panelLoader: Loader {
source: panelEntry.sourceUrl
@@ -1477,7 +1513,7 @@ ShellRoot {
}
shell.pluginReloading = false
shell._syncServices()
shell.panelEntries = shell.computePanelEntries()
shell.syncPanelEntries()
shell.syncPluginWidgets()
}
}
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const shellQml = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8')
// Handing the Instantiator a fresh array rebuilt every panel on each plugin
// change, several times during startup, so a keepLoaded panel such as the OSD
// could load twice at once and register its IPC handler twice.
assert(
/Instantiator \{\s*model: panelEntryModel/.test(shellQml) && !shellQml.includes('panelEntries'),
'panel loaders come from a model synced in place, not a reassigned array'
)
assert(
/if \(next && next\.kind === row\.entryKind && next\.keepLoaded === row\.keepLoaded && next\.sourceUrl === row\.sourceUrl\) \{\s*delete wanted\[row\.pluginId\]/.test(shellQml),
'a panel whose plugin still loads the same way keeps its loader'
)
assert(
/function unloadPanels\(\) \{[\s\S]*?panelEntryModel\.clear\(\)/.test(shellQml),
'a full plugin reload still rebuilds every panel from fresh code'
)
assert(
(shellQml.match(/shell\.syncPanelEntries\(\)/g) || []).length === 2,
'plugin changes and finished scans sync the panel entries'
)
JS