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..321bbbdb 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -230,6 +230,24 @@ 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 per bar, and the bar is +# instantiated once per screen, so Quickshell reports one collision per screen +# past the first. Anything beyond that is two instances on the same screen — +# the shape duplicate component loads produced, where a sync pass that ran +# while a widget's asynchronous load was still in flight started a second one. +# Checked before the reload below, which rebuilds widgets by design. +screens=$(hyprctl -j monitors 2>/dev/null | jq 'length' 2>/dev/null || true) +[[ $screens =~ ^[0-9]+$ ]] && (( screens > 0 )) || screens=1 +# No matches is the good case, and pipefail would otherwise abort the run. +worst=$(grep -oE "another handler is registered for target [a-z.-]+" "$log" | + sort | uniq -c | sort -rn | head -1 | awk '{print $1}' || true) +worst=${worst:-0} +if (( worst > screens - 1 )); then + grep "another handler is registered for target" "$log" | sed 's/^/ /' | head -20 >&2 + fail_with_log "each widget registers its IPC handler once per screen (saw $worst for $screens screen(s))" +fi +pass "each widget registers its IPC handler once per screen" + 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 +275,4 @@ jq -e 'all(.[]; .id != "omarchy.audio")' <<<"$geometry" >/dev/null || { } pass "bar remove reloads shell config and updates bar layout" +