From 64810d815b16fd261e802328b562b983bbec5def Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 22:19:57 -0700 Subject: [PATCH 1/2] Load each bar widget's component once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt.createComponent is asynchronous, but a widget was only recorded in pluginWidgetComponents once its component finished. syncPluginWidgets runs several times while the shell starts, and every pass that ran while a load was still in flight could not tell it apart from one that had never happened — so it started a second load of the same URL. All twenty bar widgets were loaded twice. The duplicate component then replaced the first in the registry, and swapping a slot's component makes its Loader build the replacement before dropping the original. Both were briefly live, and both registered the widget's IPC handler, which is where the "another handler is registered for target" warnings came from. Claim the key when the load starts instead, and release it if the component fails so a later rescan can retry. Co-Authored-By: Claude Opus 5 (1M context) --- shell/shell.qml | 26 ++++++++++++++++++++++---- test/shell.d/runtime-smoke-test.sh | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) 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" + From 7a6e34f153760cbb574f0541814e0bafa5ebc740 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 22:29:48 -0700 Subject: [PATCH 2/2] Scale the IPC handler check to the number of screens The bar is instantiated once per screen, so each widget registers its IPC handler once per screen and Quickshell reports a collision for every screen past the first. Failing on any collision would have failed the suite on multi-monitor desktops for behaviour that is correct; allow one per screen and fail only beyond that, which is still what duplicate component loads produce. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/runtime-smoke-test.sh | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index d6006b73..321bbbdb 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -230,18 +230,23 @@ 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 +# 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 while starting" + 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 while starting" +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