fix(desktop): recover from corrupt cached Electron download on build

hermes desktop failed on Linux with an ENOENT renaming
release/linux-unpacked/electron -> Hermes. Root cause is a corrupt
cached Electron zip (~/.cache/electron/electron-*.zip): app-builder
unpack-electron extracts a partial tree from the bad zip that is
missing the electron binary, so electron-builder dies on the final
rename. Re-running repeats the broken extraction, leaving the desktop
app permanently unlaunchable until the cache is manually purged.

- Add _electron_download_cache_dirs() + _purge_corrupt_electron_cache()
  to hermes_cli/main.py: validate every electron-*.zip via
  zipfile.testzip() and delete corrupt ones; honor electron_config_cache
  / ELECTRON_CACHE overrides with per-OS defaults.
- Wire purge + single retry into cmd_gui packaged-build failure path so
  a poisoned download self-heals (electron re-downloads clean).
- Add beforePack hook (apps/desktop/scripts/before-pack.cjs) to wipe the
  target unpacked dir before staging, making packaging idempotent across
  interrupted runs. Cross-platform, best-effort.
- Tests: corrupt-zip detector, cmd_gui purge/retry/launch path,
  no-retry-when-clean path, and node --test for the cleanup helper.
This commit is contained in:
Harry Riddle 2026-06-03 00:43:07 +07:00 committed by Teknium
parent e003c53b06
commit f583c6ebd5
5 changed files with 311 additions and 0 deletions

View file

@ -411,3 +411,91 @@ def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
cli_main._DESKTOP_STAMP_SPEC = None
h3 = cli_main._compute_desktop_content_hash(root)
assert h1 != h3, "changing a tracked file should change the hash"
def _write_zip(path: Path) -> None:
import zipfile
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("electron", "fake binary payload")
def test_purge_corrupt_electron_cache_removes_only_bad_zips(tmp_path, monkeypatch):
cache = tmp_path / "electron-cache"
good = cache / "electron-v40.9.3-linux-x64.zip"
bad = cache / "hashdir" / "electron-v40.9.3-linux-x64.zip"
_write_zip(good)
_write_zip(bad)
# Corrupt the second zip by truncating its central directory.
bad.write_bytes(bad.read_bytes()[:20])
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
removed = cli_main._purge_corrupt_electron_cache()
assert removed == [bad]
assert good.exists()
assert not bad.exists()
def test_purge_corrupt_electron_cache_noop_when_all_valid(tmp_path, monkeypatch):
cache = tmp_path / "electron-cache"
good = cache / "electron-v40.9.3-linux-x64.zip"
_write_zip(good)
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
assert cli_main._purge_corrupt_electron_cache() == []
assert good.exists()
def test_gui_retries_pack_after_purging_corrupt_electron_cache(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main._purge_corrupt_electron_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 0
mock_purge.assert_called_once()
# First pack fails, purge runs, second pack succeeds, then launch.
assert mock_run.call_count == 3
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"]
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "run", "pack"]
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
def test_gui_does_not_retry_when_cache_is_clean(tmp_path, monkeypatch, capsys):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main._purge_corrupt_electron_cache", return_value=[]) as mock_purge, \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 1
# Purge was attempted but found nothing, so no retry pack runs.
mock_purge.assert_called_once()
assert mock_run.call_count == 1
assert "Desktop GUI build failed" in capsys.readouterr().out