From 55799c4ab09f9cf9a60aed42b79f9ecf9f44bb2b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 08:56:39 +0200 Subject: [PATCH] Poll the active power profile over D-Bus instead of powerprofilesctl The battery service ran `powerprofilesctl get` every two seconds to keep the active profile visible to the wallpaper and lock services. That command is a PyGObject script, so the shell spawned a Python interpreter for it tens of thousands of times a day. Roughly once a day one of those exits into a CPython 3.14 finalization race (python/cpython#124619): the GLib D-Bus worker thread calls PyGILState_Ensure after the interpreter is torn down and the process dies with SIGSEGV, leaving a core dump and a crash notification behind. Read the ActiveProfile property straight from power-profiles-daemon with busctl, the same way omarchy-powerprofiles-set already reads UPower. The output is JSON, so an empty or malformed reply when the daemon is not running still reads as no active profile, matching the previous behaviour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gbfh4Mi9dK6SAd1P2xMTi --- shell/plugins/services/battery/Service.qml | 26 +++++++++++++++++----- test/shell.d/video-background-test.sh | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index a1b01cff..eb7561f1 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -59,6 +59,16 @@ Item { if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true } + function parseActiveProfile(text) { + // busctl --json=short prints {"type":"s","data":"balanced"}; an empty or + // malformed reply (daemon not running) reads as no active profile. + try { + return String(JSON.parse(text).data || "").trim() + } catch (e) { + return "" + } + } + Process { id: warningProcess } Process { @@ -71,17 +81,23 @@ Item { Process { id: powerProfileReadProcess - command: ["powerprofilesctl", "get"] + // Read the property straight from the daemon rather than via + // `powerprofilesctl get`. That is a PyGObject script, and spawning a Python + // interpreter for it every two seconds trips a CPython 3.14 shutdown race + // (python/cpython#124619): the GLib D-Bus worker thread re-enters the + // interpreter after finalization and the process dies with SIGSEGV, + // leaving a core dump and a crash notification behind roughly daily. + command: ["busctl", "--json=short", "get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"] stdout: StdioCollector { waitForEnd: true - onStreamFinished: root.activePowerProfile = String(text || "").trim() + onStreamFinished: root.activePowerProfile = root.parseActiveProfile(text) } } Timer { - // powerprofilesctl has no portable monitor subcommand; keep profile changes - // visible to consumers such as the wallpaper service without requiring the - // power panel to be open. + // There is no portable way to subscribe to profile changes from QML; keep + // them visible to consumers such as the wallpaper service without requiring + // the power panel to be open. interval: 2000 running: true repeat: true diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 1a15dace..7ebad86b 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -174,7 +174,8 @@ assert( assert( batteryService.includes('property string activePowerProfile') && batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') && - batteryService.includes('["powerprofilesctl", "get"]') && + batteryService.includes('"get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"') && + !batteryService.includes('["powerprofilesctl"') && batteryService.includes('interval: 2000'), 'the battery service tracks the active power-saver profile' )