Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2494,6 +2494,25 @@ async function releaseBackendLock(updateRoot, tag) {
return { unlocked: true }
}

// Stop any independently-running gateway process before checking the venv
// lock. Gateways started via `hermes gateway run` or as a Windows service
// are NOT in our backend pool — they hold the venv shim open and block the
// update. Best-effort: if the stop fails we still check the lock and
// surface a helpful error.
try {
const venvScripts = path.join(updateRoot, 'venv', 'Scripts')
const hermesBin = path.join(venvScripts, 'hermes.exe')
const { execFileSync } = require('child_process')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use ['gateway', 'stop', '--all']. Bare gateway stop is profile-scoped (hermes_cli/subcommands/gateway.py:124-128; website/docs/developer-guide/gateway-internals.md:265), so gateways in other profiles can still hold this shared venv and trigger the existing blocker path.

execFileSync(hermesBin, ['gateway', 'stop'], {
timeout: 15000,
stdio: 'ignore',
windowsHide: true,
})
rememberLog(`[${tag}] gateway stop requested before update`)
} catch {
// Gateway may not be running, or hermes.exe may be locked — non-fatal.
}

// Collect every backend PID the desktop owns: primary window backend + pool.
const pids = []
const hermesProcess = backendConnectionState.getProcess()
Expand Down Expand Up @@ -2668,8 +2687,9 @@ async function applyUpdates(opts = {}) {
// user close the holder and retry. Restart our own backend so the app
// keeps working after the failed attempt.
const message =
'Update aborted: another process is holding the Hermes install open ' +
'(a second Hermes window or a terminal running hermes?). Close it and retry.'
'Update aborted: another process is holding the Hermes install open. ' +
'If a gateway is running, stop it with `hermes gateway stop` and retry. ' +
'Otherwise close any other Hermes windows or terminals.'

emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})
Expand Down
23 changes: 23 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6905,6 +6905,13 @@ def _update_via_zip(args):

# Copy updated files over existing installation, preserving venv/node_modules/.git
preserve = {"venv", "node_modules", ".git", ".env"}
# Subdirectories within a replaced directory that must survive the
# atomic swap. On Windows the pre-built desktop binary lives in
# apps/desktop/release/win-unpacked/ — it is NOT in the source ZIP
# so _atomic_replace_dir would delete it, breaking the shortcut.
_preserve_subdirs: dict[str, list[str]] = {
"apps": ["desktop/release"],
}
update_count = 0
for item in os.listdir(extracted):
if item in preserve:
Expand All @@ -6914,7 +6921,23 @@ def _update_via_zip(args):
if os.path.isdir(src):
# Atomic-ish replace: never leave dst half-deleted if the copy
# fails partway (the failure mode behind #49145 on Windows).
saved_subdirs: list[tuple[str, str]] = []
for sub in _preserve_subdirs.get(item, []):
sub_dst = os.path.join(dst, sub)
if os.path.isdir(sub_dst):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sub_staging is inside dst. _atomic_replace_dir(src, dst) renames all of dst to dst.hermes-update-old, moving this copy away before the restore loop runs; the subsequent rename from this path fails and the release directory is not restored. Stage it outside dst instead.

sub_staging = f"{sub_dst}.hermes-preserve"
if os.path.exists(sub_staging):
shutil.rmtree(sub_staging, ignore_errors=True)
shutil.copytree(sub_dst, sub_staging)
saved_subdirs.append((sub_dst, sub_staging))
_atomic_replace_dir(src, dst)
for sub_dst, sub_staging in saved_subdirs:
try:
# Move the preserved copy back into the new tree.
os.makedirs(os.path.dirname(sub_dst), exist_ok=True)
os.rename(sub_staging, sub_dst)
except OSError:
shutil.rmtree(sub_staging, ignore_errors=True)
else:
shutil.copy2(src, dst)
update_count += 1
Expand Down