Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions completions/caelestia.fish
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ complete -c caelestia -n $not_seen -a 'update' -d 'Update the Caelestia dotfiles
set -l commands mpris drawers wallpaper notifs
set -l not_seen "$seen shell && not $seen $commands"
complete -c caelestia -n $not_seen -s 'd' -l 'daemon' -d 'Start the shell detached'
complete -c caelestia -n $not_seen -s 'r' -l 'restart' -d 'Kill and restart the shell'
complete -c caelestia -n $not_seen -s 's' -l 'show' -d 'Print all IPC commands'
complete -c caelestia -n $not_seen -s 'l' -l 'log' -d 'Print the shell log'
complete -c caelestia -n $not_seen -l 'log-rules' -d 'Log rules to apply'
Expand Down
1 change: 1 addition & 0 deletions src/caelestia/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]:
shell_parser.set_defaults(cls=shell.Command)
shell_parser.add_argument("message", nargs="*", help="a message to send to the shell")
shell_parser.add_argument("-d", "--daemon", action="store_true", help="start the shell detached")
shell_parser.add_argument("-r", "--restart", action="store_true", help="kill and restart the shell")
shell_parser.add_argument("-s", "--show", action="store_true", help="print all shell IPC commands")
shell_parser.add_argument("-l", "--log", action="store_true", help="print the shell log")
shell_parser.add_argument("-k", "--kill", action="store_true", help="kill the shell")
Expand Down
47 changes: 47 additions & 0 deletions src/caelestia/subcommands/shell.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import json
import os
import signal
import subprocess
import time
from argparse import Namespace

from caelestia.utils.io import warn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from caelestia.utils.io import warn
from caelestia.utils.io import fatal, warn

Necessary for change on L91

from caelestia.utils.paths import c_cache_dir


Expand All @@ -24,6 +29,11 @@ def run(self) -> None:
# Send a message
self.message(*self.args.message)
else:
# Kill any running instance and wait for it to exit, otherwise `-n`
# will silently skip the relaunch
if self.args.restart:
self.stop_instances()

# Start the shell
args = ["qs", "-c", "caelestia", "-n"]
if self.args.log_rules:
Expand All @@ -43,6 +53,43 @@ def run(self) -> None:
def shell(self, *args: str) -> str:
return subprocess.check_output(["qs", "-c", "caelestia", *args], text=True)

def list_instances(self) -> list[dict]:
proc = subprocess.run(["qs", "-c", "caelestia", "list", "-j"], capture_output=True, text=True)
try:
return json.loads(proc.stdout) if proc.returncode == 0 else []
except json.JSONDecodeError:
return []
Comment on lines +56 to +61

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def list_instances(self) -> list[dict]:
proc = subprocess.run(["qs", "-c", "caelestia", "list", "-j"], capture_output=True, text=True)
try:
return json.loads(proc.stdout) if proc.returncode == 0 else []
except json.JSONDecodeError:
return []
def list_instances(self) -> list[dict]:
try:
proc = subprocess.run(["qs", "-c", "caelestia", "list", "-j"], capture_output=True, text=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"failed to list shell instances: {e.stderr.strip()}") from e
if not proc.stdout.strip():
return []
try:
return json.loads(proc.stdout)
except json.JSONDecodeError as e:
raise RuntimeError("failed to parse shell instance list") from e

Currently the following three results all mean "no instances":

  • qs list reports no running shell
  • qs list fails
  • qs list returns some malformed or plain text output instead of JSON

These should be differently sincfe wait_for_exit interprets [] as indicating the old shell is actually gone, and then launches another shell with qs -c caelestia -n. The -n flag returns as successful if Quickshell finds an existing instance, so failing to poll properly would just reintroduce the bug this PR is trying to solve in the first place.

Quickshell also does not emit a JSON array when no instances are running:

danny@halos $ qs -c caelestia list -j
No running instances for "/home/danny/.config/quickshell/caelestia/shell.qml"
Use --all to list all instances.

This means an empty stdout needs to mean [], but a non-zero exit or empty JSON should be an error.


def wait_for_exit(self, timeout: float) -> bool:
end = time.monotonic() + timeout
while time.monotonic() < end:
if not self.list_instances():
return True
time.sleep(0.1)
return False

def stop_instances(self) -> None:
instances = self.list_instances()
if not instances:
return

subprocess.run(["qs", "-c", "caelestia", "kill"], capture_output=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
subprocess.run(["qs", "-c", "caelestia", "kill"], capture_output=True)
subprocess.run(["qs", "-c", "caelestia", "kill"], check=False, capture_output=True)

Satisfies linting from Ruff, PLW1510.


# Teardown is not instant (and slowest while a session lock is up)
if self.wait_for_exit(5):
return

# The instance is stuck; force kill it so the restart still happens
warn("shell did not exit gracefully, killing")
for instance in instances:
Comment on lines +83 to +84

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
warn("shell did not exit gracefully, killing")
for instance in instances:
warn("shell did not exit gracefully, killing")
instances = self.list_instances()
for instance in instances:

The instance snapshot iterated here is stale after the 5s wait_for_exit and the original process may have exited and had it's PID reused, or a matching instance could have appeared in the meantime. While not as likely to occur in practice, it's an unnecessary risk that can just be avoided by taking a fresh snapshot of the current instances.

try:
os.kill(instance["pid"], signal.SIGKILL)
except (KeyError, ProcessLookupError):
pass

if not self.wait_for_exit(2):
warn("an instance of the shell is still running")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
warn("an instance of the shell is still running")
fatal("an instance of the shell is still running")

If an instance is still present after SIGKILL, the restart hasn't actually fulfilled the precondition of killing the running instance. This could proceed onto qs -c caelestia -n and just keep the existing instance alive which will no-op as mentioned earlier. It should instead abort as non-zero and write out the error, and fatal is best for that. This makes the restart path either genuinely restart the shell or fail, instead of possibly reporting success without actually restarting.


def filter_log(self, line: str) -> bool:
return f"Cannot open: file://{c_cache_dir}/imagecache/" not in line

Expand Down