fix(linux): converge the desktop entry Exec on the durable wrapper

_resolve_hermes_bin_for_desktop_entry returned the resolver's None
outright when neither argv[0] nor PATH yielded a launcher (a cold
relaunch under `python -m` with a stripped PATH), skipping the
known-wrapper probe its own docstring describes. The persisted
`Exec=` then flipped to the bare module form, while a DE- or
terminal-launched context renders the wrapper form.

Every flip rewrites ~/.local/share/applications/hermes.desktop on the
next launch. gnome-shell 50.x removes a ShellApp from id_to_app on any
.desktop content change without checking its state; if the app was
still STARTING, the last strong reference drops and a later GC-triggered
dispose trips shell-app.c's `state == STOPPED` assertion — taking the
whole Wayland session down (observed crash, 50.4-1.fc44).

Probe the installer's known wrapper locations on the None path too, so
the entry converges on the durable wrapper wherever one exists and only
falls back to the module form when none does. A missing entry being
(re)created can still change the file regardless — this removes the
gratuitous rewrites, not the rescan trigger itself.
This commit is contained in:
Octopustank
2026-09-14 19:51:27 +08:00
committed by Teknium
parent f876ba60ff
commit a2a7cf922a
2 changed files with 58 additions and 2 deletions
+10 -2
View File
@@ -210,8 +210,16 @@ def _resolve_hermes_bin_for_desktop_entry(
sys.argv[0] = original_argv0
if not primary:
return primary
if rerouted is not None:
# No launcher resolvable from argv[0]/PATH (e.g. a cold relaunch):
# do NOT return None here — that skipped the durable-wrapper probe
# below and persisted the module form with a checkout-absolute
# interpreter, which differs from what the DE-launched form renders.
# Any content change rewrites hermes.desktop, and gnome-shell 50.x
# crashes when the entry changes while its ShellApp is STARTING
# (shell_app_dispose assertion). Probe the known locations first;
# only with no durable wrapper anywhere fall back to the module form.
pass
elif rerouted is not None:
return rerouted or primary
# argv[0] was checkout-internal AND PATH had no `hermes` — common in stripped systemd user
@@ -421,6 +421,54 @@ def test_exec_uses_known_wrapper_when_path_lookup_misses(
assert exec_line == f"{known_wrapper} desktop"
def test_exec_finds_known_wrapper_when_resolver_has_no_candidate(
tmp_path, xdg_home, monkeypatch
):
"""`None` from the resolver must still probe known wrapper locations.
A cold relaunch (argv[0] is not an executable file, e.g. `-c` under
`python -m`, and PATH has no `hermes`) makes resolve_hermes_bin return
None outright. The early `return primary` that used to fire here skipped
the durable-wrapper probe, so the persisted Exec flipped to the bare
`<python> -m hermes_cli.main desktop` module form. Each flip between the
wrapper and module forms rewrites hermes.desktop on the next launch; any
rewrite that lands while gnome-shell's ShellApp for the entry is still
STARTING crashes the shell (shell_app_dispose `state == STOPPED`
assertion, gnome-shell 50.4). The entry must converge on the durable
wrapper wherever it exists.
"""
root = _make_project(tmp_path)
known_wrapper = tmp_path / "cold-home" / ".local" / "bin" / "hermes"
known_wrapper.parent.mkdir(parents=True)
known_wrapper.write_text(
f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
encoding="utf-8",
)
known_wrapper.chmod(0o755)
monkeypatch.setenv("HOME", str(tmp_path / "cold-home"))
# argv[0] is not an executable path at all — the resolver's own chain
# yields None with or without argv[0].
_argv0_context(monkeypatch, "-c")
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: None)
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
entry = lde.install_desktop_entry(root)
assert entry is not None
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
assert exec_line == f"{known_wrapper} desktop"
# …and the SAME context a second time re-renders byte-identical content:
# the no-op guard in install_desktop_entry then skips the rewrite.
lde._probe_cache.clear()
entry2 = lde.install_desktop_entry(root)
assert entry2 is not None
assert entry2.read_text(encoding="utf-8") == entry.read_text(encoding="utf-8")
def test_exec_rejects_known_wrapper_from_another_checkout(
tmp_path, xdg_home, monkeypatch
):