fix(desktop): purge electron cache unconditionally, not via stdlib zipfile gate

The salvaged detector validated each cached electron-*.zip with
zipfile.testzip() and only purged ones it judged corrupt. But stdlib
zipfile reads from the end-of-central-directory backward, so it silently
tolerates prepended/concatenated junk — which is exactly the corruption
the bug report names ('86257938 extra bytes at beginning or within
zipfile', a partial download resumed into the same file). testzip()
returns clean on those zips, so the self-heal never fired for the
reported failure mode.

Drop the self-rolled validator: on any packaged-build failure, purge the
version's cached zips AND the half-written unpacked dir, then retry once.
@electron/get re-downloads with its own SHASUM verification — the real
source of truth, which catches prepend/concat/truncate alike. An
unrelated failure just costs one clean re-download and fails the same way.

Verified empirically: zipfile.testzip() returns None (clean) on a
prepended-junk zip; the unconditional purge removes it correctly.
This commit is contained in:
Teknium 2026-06-04 05:40:03 -07:00
parent f583c6ebd5
commit fef04a197e
2 changed files with 100 additions and 55 deletions

View file

@ -413,6 +413,8 @@ def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
assert h1 != h3, "changing a tracked file should change the hash"
# ── Electron build-cache recovery tests ───────────────────────────────
def _write_zip(path: Path) -> None:
import zipfile
@ -422,37 +424,53 @@ def _write_zip(path: Path) -> None:
zf.writestr("electron", "fake binary payload")
def test_purge_corrupt_electron_cache_removes_only_bad_zips(tmp_path, monkeypatch):
def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, monkeypatch):
"""Purge is unconditional: it removes every electron-*.zip (regardless of
whether stdlib zipfile thinks it's corrupt) plus the half-written unpacked
dir, because @electron/get's own SHASUM check on re-download is the real
validator not a self-rolled one."""
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])
# A "clean" zip and a prepended-junk zip — the latter is the real-world
# corruption that zipfile.testzip() silently passes (it reads from the
# end-of-central-directory backward), which is why we don't gate on it.
clean = cache / "electron-v40.9.3-linux-x64.zip"
prepended = cache / "hashdir" / "electron-v40.9.3-linux-x64.zip"
_write_zip(clean)
_write_zip(prepended)
prepended.write_bytes(b"\x00" * 4096 + prepended.read_bytes())
desktop_dir = tmp_path / "apps" / "desktop"
unpacked = desktop_dir / "release" / "linux-unpacked"
unpacked.mkdir(parents=True)
(unpacked / "LICENSE.electron.txt").write_text("x", encoding="utf-8")
(unpacked / "resources.pak").write_text("x", encoding="utf-8")
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
removed = cli_main._purge_corrupt_electron_cache()
removed = cli_main._purge_electron_build_cache(desktop_dir)
assert removed == [bad]
assert good.exists()
assert not bad.exists()
assert clean in removed
assert prepended in removed
assert unpacked in removed
assert not clean.exists()
assert not prepended.exists()
assert not unpacked.exists()
def test_purge_corrupt_electron_cache_noop_when_all_valid(tmp_path, monkeypatch):
def test_purge_electron_build_cache_empty_when_nothing_present(tmp_path, monkeypatch):
"""No cached zips and no unpacked dir → nothing removed, so the caller
knows a retry is pointless."""
cache = tmp_path / "electron-cache"
good = cache / "electron-v40.9.3-linux-x64.zip"
_write_zip(good)
cache.mkdir()
desktop_dir = tmp_path / "apps" / "desktop"
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
assert cli_main._purge_corrupt_electron_cache() == []
assert good.exists()
assert cli_main._purge_electron_build_cache(desktop_dir) == []
def test_gui_retries_pack_after_purging_corrupt_electron_cache(tmp_path, monkeypatch):
def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
"""First pack fails, purge clears the cache, second pack succeeds, launch."""
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")
@ -464,21 +482,24 @@ def test_gui_retries_pack_after_purging_corrupt_electron_cache(tmp_path, monkeyp
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._desktop_linux_sandbox_fixup", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._purge_electron_build_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.
# pack(fail) → purge → pack(ok) → launch = 3 subprocess.run calls
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):
def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""If the purge clears nothing, there's no point retrying — fail fast."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
@ -489,13 +510,12 @@ def test_gui_does_not_retry_when_cache_is_clean(tmp_path, monkeypatch, capsys):
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._purge_electron_build_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