Honor keepLoaded for services during plugin hot-reload (#9485)
* Honor keepLoaded for services during plugin hot-reload Plugin reload destroyed every service, including omarchy.lock, which drops the ext-session-lock client while Hyprland still holds the lock and surfaces the crashed-lockscreen fallback. * Prove keepLoaded service survival with a fixture service A fresh lock service also reports an empty lastEventAt, so comparing it across the rescan passed whether or not the instance survived. A fixture keepLoaded service whose in-memory marker is set before the rescan and read back after can only pass when the same instance is still mounted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop kept services whose plugin no longer declares a service The _syncServices cleanup only asked whether the plugin was still installed and enabled, so a kept service whose plugin dropped its service kind or entry point kept running as a zombie until shell restart. Apply the same eligibility checks used at creation, and hand kept instances the refreshed manifest after a rescan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Cover omarchy.media in keepLoaded expectations; note kept services reload on restart Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+122
-9
No files matched your search
@@ -37,9 +37,7 @@ wait).
|
|||||||
|
|
||||||
Only one full bar option is active at a time. The built-in `omarchy.bar` is
|
Only one full bar option is active at a time. The built-in `omarchy.bar` is
|
||||||
used when `bar.id` is omitted or when a selected third-party bar cannot load.
|
used when `bar.id` is omitted or when a selected third-party bar cannot load.
|
||||||
Panels, overlays, and menus are loaded when summoned. Plugins can set the
|
Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup.
|
||||||
top-level manifest key `keepLoaded: true` to survive between summons.
|
|
||||||
First-party services are loaded at startup.
|
|
||||||
|
|
||||||
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose
|
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose
|
||||||
`open(payloadJson)` and `close()` for summon/hide; on load the host injects
|
`open(payloadJson)` and `close()` for summon/hide; on load the host injects
|
||||||
|
|||||||
+6
-2
@@ -86,8 +86,12 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall
|
|||||||
back to the built-in `omarchy.bar`, so users always have a safe path home.
|
back to the built-in `omarchy.bar`, so users always have a safe path home.
|
||||||
Panels, overlays, and menus are loaded when summoned. Plugins that need
|
Panels, overlays, and menus are loaded when summoned. Plugins that need
|
||||||
to outlive a single summon can set `keepLoaded: true` (e.g. the image
|
to outlive a single summon can set `keepLoaded: true` (e.g. the image
|
||||||
picker keeps its overlay window mounted between summons). First-party
|
picker keeps its overlay window mounted between summons). The same flag
|
||||||
services are loaded at startup.
|
keeps a service mounted across plugin hot-reload, so tearing down a
|
||||||
|
changed bar widget cannot destroy `omarchy.lock` while Hyprland still
|
||||||
|
holds the session lock. The kept instance is not replaced, so code
|
||||||
|
changes to a `keepLoaded` service itself only take effect on a shell
|
||||||
|
restart. First-party services are loaded at startup.
|
||||||
|
|
||||||
The full schema lives in `services/PluginRegistry.qml`.
|
The full schema lives in `services/PluginRegistry.qml`.
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and,
|
|||||||
only when fingerprints are enrolled, `omarchy-lock-fingerprint` for
|
only when fingerprints are enrolled, `omarchy-lock-fingerprint` for
|
||||||
fingerprint auth. It mirrors the previous lock screen field dimensions,
|
fingerprint auth. It mirrors the previous lock screen field dimensions,
|
||||||
colors, blurred wallpaper, placeholder, and Hyprland-driven corners.
|
colors, blurred wallpaper, placeholder, and Hyprland-driven corners.
|
||||||
|
The plugin sets `keepLoaded: true` so a plugin hot-reload (for example
|
||||||
|
an installed bar widget changing on disk) does not destroy the lock
|
||||||
|
client while Hyprland still holds the session lock.
|
||||||
|
|
||||||
## Polkit agent
|
## Polkit agent
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -329,14 +329,23 @@ ShellRoot {
|
|||||||
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
|
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
|
||||||
if (!m.entryPoints || !m.entryPoints.service) continue
|
if (!m.entryPoints || !m.entryPoints.service) continue
|
||||||
if (!pluginRegistry.isEnabled(id)) continue
|
if (!pluginRegistry.isEnabled(id)) continue
|
||||||
if (_services[id]) continue
|
if (_services[id]) {
|
||||||
|
// A kept instance outlives the rescan; hand it the fresh manifest.
|
||||||
|
var kept = _services[id]
|
||||||
|
if (kept && "manifest" in kept) kept.manifest = m
|
||||||
|
continue
|
||||||
|
}
|
||||||
ensureService(id)
|
ensureService(id)
|
||||||
}
|
}
|
||||||
// Drop services for plugins that have been disabled or removed.
|
// Drop services for plugins that have been disabled or removed, or that
|
||||||
|
// no longer declare a service entry point.
|
||||||
for (var existingId in _services) {
|
for (var existingId in _services) {
|
||||||
var stillThere = plugins[existingId]
|
var stillThere = plugins[existingId]
|
||||||
|
var stillService = stillThere && Array.isArray(stillThere.kinds)
|
||||||
|
&& stillThere.kinds.indexOf("service") !== -1
|
||||||
|
&& stillThere.entryPoints && stillThere.entryPoints.service
|
||||||
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
|
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
|
||||||
if (stillThere && stillEnabled) continue
|
if (stillService && stillEnabled) continue
|
||||||
var inst = _services[existingId]
|
var inst = _services[existingId]
|
||||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||||
var next = ({})
|
var next = ({})
|
||||||
@@ -345,12 +354,26 @@ ShellRoot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceKeepLoaded(pluginId) {
|
||||||
|
var plugins = pluginRegistry && pluginRegistry.installedPlugins
|
||||||
|
var manifest = plugins ? plugins[pluginId] : null
|
||||||
|
return !!(manifest && manifest.keepLoaded === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepLoaded services (lock, idle, polkit) must survive plugin hot-reload.
|
||||||
|
// Destroying omarchy.lock drops the ext-session-lock client while Hyprland
|
||||||
|
// still holds the lock, which surfaces the crashed-lockscreen fallback.
|
||||||
function unloadPluginServices() {
|
function unloadPluginServices() {
|
||||||
|
var next = ({})
|
||||||
for (var existingId in _services) {
|
for (var existingId in _services) {
|
||||||
|
if (serviceKeepLoaded(existingId)) {
|
||||||
|
next[existingId] = _services[existingId]
|
||||||
|
continue
|
||||||
|
}
|
||||||
var inst = _services[existingId]
|
var inst = _services[existingId]
|
||||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||||
}
|
}
|
||||||
_services = ({})
|
_services = next
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
|
|||||||
@@ -197,5 +197,18 @@ for (const [id, section] of Object.entries({
|
|||||||
}
|
}
|
||||||
check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback')
|
check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback')
|
||||||
|
|
||||||
|
for (const id of ['omarchy.lock', 'omarchy.idle', 'omarchy.polkit', 'omarchy.notifications', 'omarchy.media']) {
|
||||||
|
check(byId[id]?.keepLoaded === true, `${id} must stay loaded across plugin reloads`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8')
|
||||||
|
const unloadMatch = shellSource.match(/function unloadPluginServices\(\) \{[\s\S]*?\n \}/)
|
||||||
|
check(!!unloadMatch, 'unloadPluginServices is defined')
|
||||||
|
check(!!unloadMatch && /serviceKeepLoaded/.test(unloadMatch[0]), 'unloadPluginServices honors keepLoaded')
|
||||||
|
check(
|
||||||
|
/function _syncServices\(\) \{[\s\S]*Drop services for plugins that have been disabled/.test(shellSource),
|
||||||
|
'_syncServices still drops disabled or removed services'
|
||||||
|
)
|
||||||
|
|
||||||
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
|
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
|
||||||
JS
|
JS
|
||||||
@@ -74,6 +74,44 @@ Item {
|
|||||||
}
|
}
|
||||||
QML
|
QML
|
||||||
|
|
||||||
|
# A keepLoaded service must keep its instance (and in-memory state) across a
|
||||||
|
# plugin rescan. The marker below can only survive if the object does.
|
||||||
|
keep_service_id="acme.keep-service"
|
||||||
|
keep_service_dir="$test_home/.config/omarchy/plugins/$keep_service_id"
|
||||||
|
mkdir -p "$keep_service_dir"
|
||||||
|
cat >"$keep_service_dir/manifest.json" <<JSON
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "$keep_service_id",
|
||||||
|
"name": "Keep Service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"kinds": ["service"],
|
||||||
|
"keepLoaded": true,
|
||||||
|
"entryPoints": {"service": "Service.qml"}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
cat >"$keep_service_dir/Service.qml" <<'QML'
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
Item {
|
||||||
|
property string marker: ""
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "acme-keep"
|
||||||
|
|
||||||
|
function set(value: string): string {
|
||||||
|
marker = value
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(): string {
|
||||||
|
return marker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QML
|
||||||
|
|
||||||
cat >"$stub_bin/omarchy-update-available" <<'SH'
|
cat >"$stub_bin/omarchy-update-available" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo "Omarchy update available (test)"
|
echo "Omarchy update available (test)"
|
||||||
@@ -188,6 +226,15 @@ pass "shell IPC summon and hide contract works"
|
|||||||
jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON"
|
jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON"
|
||||||
jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON"
|
jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON"
|
||||||
jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON"
|
jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON"
|
||||||
|
[[ $(shell_ipc shell setPluginEnabled "$keep_service_id" true) == "ok" ]] ||
|
||||||
|
fail_with_log "keepLoaded fixture service could not be enabled"
|
||||||
|
keep_marker_set=""
|
||||||
|
for _ in {1..80}; do
|
||||||
|
keep_marker_set=$(shell_ipc acme-keep set "survived" 2>/dev/null || true)
|
||||||
|
[[ $keep_marker_set == "ok" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ $keep_marker_set == "ok" ]] || fail_with_log "keepLoaded fixture service IPC responds"
|
||||||
[[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds"
|
[[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds"
|
||||||
[[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds"
|
[[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds"
|
||||||
[[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens"
|
[[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens"
|
||||||
@@ -215,6 +262,31 @@ shell_ipc_quiet image-selector cancel "$selector_done_file" >/dev/null
|
|||||||
rm -f "$selector_selection_file" "$selector_done_file"
|
rm -f "$selector_selection_file" "$selector_done_file"
|
||||||
pass "image selector IPC survives plugin rescan"
|
pass "image selector IPC survives plugin rescan"
|
||||||
|
|
||||||
|
lock_status_after=$(shell_ipc lock status)
|
||||||
|
jq -e '.locked | type == "boolean"' <<<"$lock_status_after" >/dev/null || fail_with_log "lock IPC survives plugin rescan"
|
||||||
|
lock_event_after=$(jq -r '.lastEvent // empty' <<<"$lock_status_after")
|
||||||
|
[[ $lock_event_after != lock-stranded* ]] ||
|
||||||
|
fail_with_log "plugin rescan does not strand the session lock ($lock_event_after)"
|
||||||
|
# A recreated instance would answer with a fresh, empty marker.
|
||||||
|
[[ $(shell_ipc acme-keep get) == "survived" ]] ||
|
||||||
|
fail_with_log "plugin rescan keeps the keepLoaded service instance mounted"
|
||||||
|
pass "keepLoaded service instance survives plugin rescan"
|
||||||
|
|
||||||
|
# Dropping the service entry point from the manifest must drop the kept
|
||||||
|
# instance instead of leaving a zombie behind.
|
||||||
|
jq 'del(.keepLoaded) | .kinds = ["overlay"] | .entryPoints = {"overlay": "Service.qml"}' \
|
||||||
|
"$keep_service_dir/manifest.json" >"$keep_service_dir/manifest.json.tmp"
|
||||||
|
mv "$keep_service_dir/manifest.json.tmp" "$keep_service_dir/manifest.json"
|
||||||
|
keep_gone=""
|
||||||
|
for _ in {1..80}; do
|
||||||
|
keep_gone=$(shell_ipc acme-keep get 2>/dev/null || true)
|
||||||
|
[[ $keep_gone != "survived" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ $keep_gone != "survived" ]] ||
|
||||||
|
fail_with_log "kept service is dropped when its plugin stops declaring a service"
|
||||||
|
pass "kept service is dropped when its plugin stops declaring a service"
|
||||||
|
|
||||||
shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true
|
shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true
|
||||||
sleep 0.8
|
sleep 0.8
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user