Windows auto-update infinite retry loop and self-lock recovery
Cluster 45542 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #54135 on 28 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #54135. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
The three members all concern an infinite auto-update or recovery loop on Windows, involving a .update-incomplete marker and hermes.exe locking itself. One is a bug report and two are fix proposals (desktop and CLI); the titles do not show whether any change has been merged or has worked.
Open issues0
Open PRs0
Fixes on record
-
#54135 fix(windows): break the desktop auto-update infinite loop (#52378)
files:
apps/desktop/electron/backend-probes.cjs,apps/desktop/electron/backend-probes.test.cjs,apps/desktop/electron/main.cjs,apps/desktop/electron/windows-hermes-resolution.test.cjs,apps/desktop/package.json,hermes_cli/main.py,infographic/windows-update-loop-52378/infographic.png,scripts/release.py,tests/hermes_cli/test_update_interrupted_recovery.pyShow the diff
-
apps/desktop/electron/backend-probes.cjs@@ -37,7 +37,18 @@ const { execFileSync } = require('node:child_process') const PROBE_TIMEOUT_MS = 5000 /** - * Return true iff `python -c "import hermes_cli"` exits 0. + * Return the Python snippet used to verify Hermes can import far enough to + * launch the CLI. Kept exported for tests so dependency regressions are + * caught without needing a real broken venv fixture. + * + * @returns {string} + */ +function hermesRuntimeImportProbe() { + return 'import yaml; import hermes_cli.config' +} + +/** + * Return true iff the Hermes runtime import probe exits 0. * * Used to gate the "fallback to system Python with hermes_cli installed" * rung of resolveHermesBackend. Without this, a system Python 3.11-3.13 @@ -46,13 +57,20 @@ const PROBE_TIMEOUT_MS = 5000 * site-packages -- and the resolver returns a backend that immediately * dies on spawn. * + * The probe intentionally imports hermes_cli.config, not just the top-level + * package: a broken/empty Windows launcher venv can still see the source tree + * through PYTHONPATH but lack PyYAML, then die on the first real CLI import. + * * @param {string} pythonPath - Absolute path to a python.exe / python. + * @param {object} [opts] + * @param {object} [opts.env] - Additional environment for the probe. * @returns {boolean} */ -function canImportHermesCli(pythonPath) { +function canImportHermesCli(pythonPath, opts = {}) { if (!pythonPath) return false try { - execFileSync(pythonPath, ['-c', 'import hermes_cli'], { + execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], { + env: { ...process.env, ...(opts.env || {}) }, stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true @@ -101,6 +119,7 @@ function verifyHermesCli(hermesCommand, opts = {}) { module.exports = { canImportHermesCli, + hermesRuntimeImportProbe, verifyHermesCli, PROBE_TIMEOUT_MS } -
apps/desktop/electron/backend-probes.test.cjs@@ -11,7 +11,7 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') +const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs') // Resolve the host's own Node binary -- guaranteed to be on disk and // runnable. We use it as both a stand-in for "a python that doesn't @@ -40,6 +40,12 @@ test('canImportHermesCli returns false when binary does not exist', () => { assert.equal(canImportHermesCli(ghost), false) }) +test('hermes runtime import probe checks config dependencies', () => { + const probe = hermesRuntimeImportProbe() + assert.match(probe, /\bimport yaml\b/) + assert.match(probe, /\bimport hermes_cli\.config\b/) +}) + test('verifyHermesCli returns false when command is falsy', () => { assert.equal(verifyHermesCli(''), false) assert.equal(verifyHermesCli(null), false) -
apps/desktop/electron/main.cjs@@ -1285,8 +1285,14 @@ function findOnPath(command) { const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. + // A real command must resolve via its .exe/.cmd (Windows command-resolution + // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash + // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`. + // The empty entry is kept LAST so callers that already include the extension + // (py.exe, pwsh.exe, powershell.exe) still resolve. const extensions = IS_WINDOWS - ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] + ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] : [''] for (const entry of pathEntries) { @@ -2243,7 +2249,18 @@ async function handOffWindowsBootstrapRecovery(reason) { : configuredBranch || DEFAULT_UPDATE_BRANCH const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') - const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, + // not just the `hermes.exe` console-script shim. That shim is generated at the + // END of venv setup and is absent in exactly the interrupted/quarantined states + // this recovery exists to heal — gating on it alone forced the destructive + // --repair (full venv recreate) and drove reinstall loops. The venv interpreter + // and the bootstrap-complete marker are present earlier and are better signals. + const haveRealInstall = + fileExists(venvPython) || + fileExists(venvHermes) || + fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) + const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] await releaseBackendLockForUpdate(updateRoot) @@ -2608,6 +2625,24 @@ function readBootstrapMarker() { return readJson(BOOTSTRAP_COMPLETE_MARKER) } +// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually +// runnable right now? A complete CLI install (`install.sh --include-desktop`) +// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop +// ever having written the bootstrap marker -- so we must be able to recognise +// "already installed" off the filesystem alone, not just the marker. +function isActiveRuntimeUsable() { + const venvPython = getVenvPython(VENV_ROOT) + return ( + isHermesSourceRoot(ACTIVE_HERMES_ROOT) && + fileExists(venvPython) && + canImportHermesCli(venvPython, { + env: { + PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) + } + }) + ) +} + function isBootstrapComplete() { const marker = readBootstrapMarker() if (!marker || typeof marker !== 'object') return false @@ -2620,7 +2655,7 @@ function isBootstrapComplete() { // a runnable venv: an interrupted or split-home install can leave the marker // + checkout without a venv, and trusting that spawns a dead backend // ("gateway offline") instead of re-running bootstrap to repair it. - return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT)) + return isActiveRuntimeUsable() } function writeBootstrapMarker(payload) { -
apps/desktop/electron/windows-hermes-resolution.test.cjs@@ -0,0 +1,67 @@ +'use strict' + +// Regression guards for Windows `hermes` resolution in main.cjs. +// +// main.cjs has no module.exports, so these follow the repo's source-assertion +// test pattern (see windows-child-process.test.cjs). They pin the two Windows +// resolution bugs that caused desktop reinstall loops: +// 1. findOnPath() tried the empty extension FIRST, so an extensionless +// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the +// shim then failed the --version probe and the desktop fell through to a +// spurious bootstrap/repair. +// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive +// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script +// shim, written at the END of venv setup and absent in interrupted +// states), so it escalated to a full venv recreate even on healthy +// installs. + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +function readMain() { + return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') +} + +test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { + const source = readMain() + // Fixed order: PATHEXT first, empty string LAST. + assert.match( + source, + /\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/, + 'extensions array must end with the empty string, not start with it' + ) + // The buggy empty-first order must not return. + assert.doesNotMatch( + source, + /\['', \.\.\.\(process\.env\.PATHEXT/, + 'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe' + ) +}) + +test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => { + const source = readMain() + assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall') + assert.match( + source, + /fileExists\(venvPython\)/, + 'recovery must accept the venv interpreter as a real-install signal' + ) + assert.match( + source, + /\.hermes-bootstrap-complete/, + 'recovery must accept the bootstrap-complete marker as a real-install signal' + ) + assert.match( + source, + /updaterArgs = haveRealInstall \? \['--update'/, + 'updaterArgs must gate on haveRealInstall' + ) + // The old too-narrow check (only venv\Scripts\hermes.exe) must not return. + assert.doesNotMatch( + source, + /updaterArgs = fileExists\(venvHermes\) \?/, + 'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair' + ) +}) -
apps/desktop/package.json@@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", -
hermes_cli/main.py@@ -6992,6 +6992,55 @@ def _recover_from_interrupted_install() -> None: # the install itself will surface the real problem. logger.debug("Could not create install-recovery lock: %s", exc) + # Windows self-lock guard: if hermes.exe is the launcher that spawned + # this Python process, any attempt to pip-install will fail with + # "拒绝访问 / WinError 32" because the running .exe cannot be replaced. + # Rather than entering the permanent retry loop described in issue + # #45542, clear the marker and give the user an offline recovery command. + if _is_windows(): + scripts_dir = _venv_scripts_dir() + if scripts_dir is not None: + shims = _hermes_exe_shims(scripts_dir) + if shims: + _shim_set: set[str] = set() + for _s in shims: + try: + _shim_set.add(str(_s.resolve()).lower()) + except OSError: + _shim_set.add(str(_s).lower()) + try: + import psutil + _me = psutil.Process() + for _anc in [_me] + list(_me.parents()): + try: + _anc_exe = _anc.exe() + _anc_norm = str(Path(_anc_exe).resolve()).lower() + except Exception: + continue + if _anc_norm in _shim_set: + print( + "✗ Hermes is running from the binary that " + "needs to be replaced — the auto-recovery " + "cannot overwrite a running executable." + ) + print( + " Restart Hermes from a different terminal, " + "then run the manual recovery command below:" + ) + print(f' cd /d "{PROJECT_ROOT}"') + print( + f' "{sys.executable}" -m pip install ' + '-e ".[all]"' + ) + _clear_update_incomplete_marker() + try: + lock_path.unlink() + except OSError: + pass + return + except Exception: + pass # psutil is best-effort; fall through to install + saved_stdout_fd = None saved_sys_stdout = sys.stdout try: -
scripts/release.py@@ -167,6 +167,8 @@ "[email protected]": "synapsesx", "[email protected]": "itsflownium", "[email protected]": "dirtyren", + "[email protected]": "Cossackx", # PR #52528 salvage (Windows hermes-shim resolution + prefer --update on recovery; #52378) + "[email protected]": "Icather", # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542) "--email": "[redacted:email]", "[redacted:email]": "francescomucio", "[email protected]": "thestral123", -
tests/hermes_cli/test_update_interrupted_recovery.py@@ -139,6 +139,90 @@ class R: ) +def test_recovery_self_lock_guard_clears_marker_without_install(tmp_path, monkeypatch): + # Windows self-lock: hermes.exe is an ancestor of this Python process, so a + # pip-install would fail trying to replace the running launcher (WinError 32 + # / 拒绝访问). Recovery must short-circuit — clear the marker, skip install, + # break the loop (#45542 / #52378) — instead of retrying forever. + monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + m._write_update_incomplete_marker() + + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + shim = scripts_dir / "hermes.exe" + shim.write_text("") + + monkeypatch.setattr(m, "_is_windows", lambda: True) + monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) + monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim]) + + class FakeProc: + def __init__(self, exe_path): + self._exe = exe_path + + def exe(self): + return self._exe + + def parents(self): + return [FakeProc(str(shim))] + + monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path())) + + seen = {"install": False} + _stub_install_env(monkeypatch, m, seen) + + m._recover_from_interrupted_install() + + assert seen["install"] is False, "self-lock must skip the install" + assert not m._update_marker_path().exists(), "marker cleared to break the loop" + + +def sys_executable_path(): + import sys + + return sys.executable + + +def test_recovery_self_lock_guard_inactive_when_not_ancestor(tmp_path, monkeypatch): + # Windows, but hermes.exe is NOT in the ancestry (launched via `hermes + # dashboard` from a separate cmd, say). The guard must fall through to the + # normal install so a genuinely interrupted install still gets healed. + monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + m._write_update_incomplete_marker() + + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + shim = scripts_dir / "hermes.exe" + shim.write_text("") + + monkeypatch.setattr(m, "_is_windows", lambda: True) + monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) + monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim]) + + class FakeProc: + def __init__(self, exe_path): + self._exe = exe_path + + def exe(self): + return self._exe + + def parents(self): + # Ancestry is plain pythons / cmd — no hermes.exe shim. + return [FakeProc(str(tmp_path / "cmd.exe"))] + + monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path())) + + seen = {"install": False} + _stub_install_env(monkeypatch, m, seen) + + m._recover_from_interrupted_install() + + assert seen["install"] is True, "without self-lock, normal recovery runs" + assert not m._update_marker_path().exists(), "marker cleared on successful install" + + def test_recovery_skips_when_lock_held(tmp_path, monkeypatch): # Another process is mid-recovery (fresh lockfile) — this launch must skip # the install entirely and leave both marker and lock untouched.
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#45554 fix(cli): detect self-lock in update recovery to break infinite retry… · merged via another change →
#54135,#52378
Closed issues · 1
- Closed#45542[Bug]: .update-incomplete marker causes infinite auto-recovery loop — hermes.exe self-locked on Windows
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #54135
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 28 Jun 2026