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 ):