From a2a7cf922aa29db7ee2e370ba44a813b2795a685 Mon Sep 17 00:00:00 2001 From: Octopustank Date: Mon, 14 Sep 2026 19:51:27 +0800 Subject: [PATCH] fix(linux): converge the desktop entry Exec on the durable wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- hermes_cli/linux_desktop_entry.py | 12 ++++- tests/hermes_cli/test_linux_desktop_entry.py | 48 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index 9047ef5626..98c2304fc8 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -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 diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index acb372b2f8..06dcbf4cf7 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -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 + ` -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 ):