From 9a99c6064eac1079a3acc4260e457f6e4e439382 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 17:16:36 -0400 Subject: [PATCH] Fix nested fixture transition draining --- .../dot/quickshell/services/HomeAssistant.qml | 76 ++++++++++++++++++- config/dot/quickshell/shell.qml | 2 + .../control-center-services-contract.sh | 25 ++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/config/dot/quickshell/services/HomeAssistant.qml b/config/dot/quickshell/services/HomeAssistant.qml index 2959294..4a21782 100644 --- a/config/dot/quickshell/services/HomeAssistant.qml +++ b/config/dot/quickshell/services/HomeAssistant.qml @@ -35,10 +35,13 @@ Singleton { property bool fixtureTransitionDraining: false property bool fixtureTransitionStreamFinished: false property bool fixtureTransitionExited: false + property var pendingFixtureTarget: null + property int delayedFixtureExitCode: 0 readonly property var visibleEntities: root.selectedEntities.slice(0, 4) readonly property int discoveredCount: root.catalog.length readonly property int configuredCount: root.selectedEntities.length + readonly property bool actionProcessRunning: actionProc.running // Temporary aliases keep the current Home controls usable until the shelf // switches to the selected-entity and per-entity action interfaces. @@ -219,10 +222,14 @@ Singleton { } function fixtureActionCommand(action: var): var { + if (root.fixtureProcessMode === "delayed-exit") + return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"]; if (root.fixtureProcessMode === "no-output" && action.entityId === "light.fixture_kitchen") { return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"]; } + if (root.fixtureProcessMode === "slow-success") + return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"]; return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"]; } @@ -240,6 +247,20 @@ Singleton { } function handleActionExited(exitCode: int): void { + // This fixture-only delay gives the contract a deterministic window + // where the process stopped but its exit bookkeeping is still pending. + if (root.fixtureMode + && root.fixtureProcessMode === "delayed-exit" + && root.activeAction !== null + && !root.fixtureTransitionDraining) { + root.delayedFixtureExitCode = exitCode; + delayedFixtureExitTimer.restart(); + return; + } + root.recordActionExited(exitCode); + } + + function recordActionExited(exitCode: int): void { if (root.fixtureTransitionDraining) { root.fixtureTransitionExited = true; fixtureTransitionTimer.restart(); @@ -368,6 +389,9 @@ Singleton { } function beginFixtureTransition(): void { + if (root.fixtureTransitionDraining) + return; + const shouldDrain = root.fixtureMode && root.fixtureProcessMode !== "" && (actionProc.running || root.activeAction !== null); @@ -397,22 +421,59 @@ Singleton { if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited) return; + const target = root.pendingFixtureTarget; + root.pendingFixtureTarget = null; root.fixtureTransitionDraining = false; root.fixtureTransitionStreamFinished = false; root.fixtureTransitionExited = false; + if (target !== null) + root.installFixtureTarget(target); queueAdvanceTimer.restart(); } function applyFixture(name: string): void { if (["ready", "stale", "unavailable", "missing-selected", "action-error", - "process-actions", "process-no-output"].indexOf(name) < 0) + "process-actions", "process-no-output", "process-delayed-exit", + "process-slow-actions"].indexOf(name) < 0) return; + root.requestFixtureTarget({ fixture: true, name }); + } + + function requestFixtureTarget(target: var): void { + if (root.fixtureTransitionDraining) { + // Preserve the old process's callback guard and keep only the + // latest replacement requested during that drain. + root.pendingFixtureTarget = target; + root.resetActionState(); + return; + } + + root.pendingFixtureTarget = target; root.beginFixtureTransition(); + if (root.fixtureTransitionDraining) + return; + + root.pendingFixtureTarget = null; + root.installFixtureTarget(target); + } + + function installFixtureTarget(target: var): void { + if (target.fixture) + root.installFixture(target.name); + else + root.installLiveState(); + } + + function installFixture(name: string): void { root.fixtureMode = true; root.fixtureProcessMode = name === "process-actions" ? "success" - : (name === "process-no-output" ? "no-output" : ""); + : (name === "process-no-output" + ? "no-output" + : (name === "process-delayed-exit" + ? "delayed-exit" + : (name === "process-slow-actions" ? "slow-success" : ""))); if (name === "unavailable") { root.catalog = []; root.fixtureFavorites = []; @@ -442,7 +503,10 @@ Singleton { } function clearFixture(): void { - root.beginFixtureTransition(); + root.requestFixtureTarget({ fixture: false, name: "" }); + } + + function installLiveState(): void { root.fixtureMode = false; root.fixtureProcessMode = ""; root.phase = "loading"; @@ -496,6 +560,12 @@ Singleton { onTriggered: root.tryFinishFixtureTransition() } + Timer { + id: delayedFixtureExitTimer + interval: 1000 + onTriggered: root.recordActionExited(root.delayedFixtureExitCode) + } + Timer { interval: 60000 repeat: true diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 2b1968a..2e3f175 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -274,6 +274,8 @@ ShellRoot { busyEntityIds: HomeAssistant.busyEntityIds, pendingBrightness: HomeAssistant.pendingBrightness, entityErrors: HomeAssistant.entityErrors, + actionProcessRunning: HomeAssistant.actionProcessRunning, + actionStreamFinished: HomeAssistant.actionStreamFinished, lastError: HomeAssistant.lastError }); } diff --git a/tests/quickshell/control-center-services-contract.sh b/tests/quickshell/control-center-services-contract.sh index 9c2b473..8355574 100755 --- a/tests/quickshell/control-center-services-contract.sh +++ b/tests/quickshell/control-center-services-contract.sh @@ -209,6 +209,31 @@ wait_for_home_status ' (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'switching process fixtures stranded or misattributed the new action' +qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null +qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null +wait_for_home_status ' + .busyEntityIds == ["light.fixture_living"] and + .pendingBrightness["light.fixture_living"] == 88 and + .actionProcessRunning == false and + .actionStreamFinished == true +' 'nested fixture transition setup did not reach its delayed-exit window' +qs_for_test ipc call home-assistant fixture process-no-output >/dev/null +qs_for_test ipc call home-assistant fixture process-slow-actions >/dev/null +qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null +wait_for_home_status ' + .phase == "ready" and + .busy == false and + .busyEntityIds == [] and + .pendingBrightness == {} and + .entityErrors == {} and + (.entities[] | + select(.id == "light.fixture_living") | + .active == false and .brightnessPct == 36) and + (.entities[] | + select(.id == "light.fixture_desk") | + .active == true and .brightnessPct == 57) +' 'nested fixture transitions released the drain or corrupted the latest action' + qs_for_test ipc call home-assistant fixture missing-selected >/dev/null jq -e ' .fixture == true and