diff --git a/shell/shell.qml b/shell/shell.qml index f8b04fb0..bda18a9b 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -700,6 +700,12 @@ ShellRoot { source: "plugin" } + // A load already in flight for this URL registers itself when it + // finishes. Starting a second one produces a second Component for the + // same widget, and swapping a slot's component rebuilds its item — + // briefly running two of the widget, each registering its IPC handler. + if (existing && existing.url === url && !existing.component) continue + // If the component URL is unchanged, just refresh the metadata in // place. We can't skip this even when the URL matches: manifests can // change schema, defaults, or sourceDir between rescans, and the @@ -769,17 +775,29 @@ ShellRoot { } } + function setPluginWidgetComponent(registryKey, entry) { + var next = ({}) + for (var k in pluginWidgetComponents) if (k !== registryKey) next[k] = pluginWidgetComponents[k] + if (entry) next[registryKey] = entry + pluginWidgetComponents = next + } + function loadPluginWidget(registryKey, url, meta) { + // Claim the key before the component exists. Qt.createComponent is + // asynchronous and syncPluginWidgets runs several times while the shell + // starts, so without a marker the later passes cannot tell a load in + // flight from one that never happened. + setPluginWidgetComponent(registryKey, { url: url, component: null }) + var comp = Qt.createComponent(url, Component.Asynchronous) function finalize() { if (comp.status === Component.Ready) { shell.barWidgetRegistry.register(registryKey, comp, meta) - var next = ({}) - for (var k in pluginWidgetComponents) next[k] = pluginWidgetComponents[k] - next[registryKey] = { url: url, component: comp } - pluginWidgetComponents = next + shell.setPluginWidgetComponent(registryKey, { url: url, component: comp }) } else if (comp.status === Component.Error) { console.warn("Plugin widget " + registryKey + " failed: " + comp.errorString()) + // Drop the claim so a later rescan can retry. + shell.setPluginWidgetComponent(registryKey, null) shell.pluginRegistry.pluginLoadFailed(registryKey, comp.errorString()) } } diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index fc4a8c1f..d6006b73 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -230,6 +230,19 @@ for panel_id in omarchy.audio omarchy.bluetooth omarchy.monitor omarchy.network done pass "direct panel IPC opens and closes default panels" +# Each widget registers its IPC handler once while the shell starts. A second +# registration for the same target means two instances of that widget were +# live at once, which is what duplicate component loads produced: every sync +# pass that ran while a widget's asynchronous load was still in flight started +# a second load, and swapping a slot's component builds the replacement before +# dropping the original. Checked before the reload below, which rebuilds +# widgets by design. +if grep -q "another handler is registered for target" "$log"; then + grep "another handler is registered for target" "$log" | sed 's/^/ /' | head -20 >&2 + fail_with_log "each widget registers its IPC handler once while starting" +fi +pass "each widget registers its IPC handler once while starting" + HOME="$test_home" OMARCHY_PATH="$test_root" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-bar-plugin" remove omarchy.audio for _ in {1..80}; do @@ -257,3 +270,4 @@ jq -e 'all(.[]; .id != "omarchy.audio")' <<<"$geometry" >/dev/null || { } pass "bar remove reloads shell config and updates bar layout" +