Commit Graph

72 Commits

Author SHA1 Message Date
Teknium
51044e75e3 fix(update): the Rust installer honours HERMES_DATA_DIR_SUFFIX for the default home and marker root (review F13)
What: platform_default_home() appends HERMES_DATA_DIR_SUFFIX literally to the hermes/.hermes leaf via platform_default_home_with(suffix), which fixes hermes_home(), hermes_root() and the update marker together.
Why: Python (_get_platform_default_hermes_home) and Desktop (platformDefaultHermesHome) both append it, so a hermes-setup launched in a suffix-only channel environment read and wrote the unsuffixed home and marker.
Red: paths::tests::data_dir_suffix_names_the_default_home_and_its_marker_root on the 0ec53574011 resolution (byte-verbatim extract, suffix ignored): left '.hermes' right '.hermes-channel-build-x'.
Green: passes on the real fixed source extract; also cargo check clean for x86_64-pc-windows-msvc (Tauri crate itself cannot build on this host).
2026-10-06 04:36:02 -07:00
Teknium
fc7d24650d fix(update): an exited Windows owner with exit code 259 is dead, not running forever (review F10)
What: the Windows pid_is_alive opens the process with SYNCHRONIZE and returns WaitForSingleObject(h, 0) == WAIT_TIMEOUT; access denied still reads alive via liveness_from_open_error.
Why: GetExitCodeProcess == STILL_ACTIVE (259) is also what a process that exited with code 259 reports while any handle keeps its object, and its creation time still matches the v2 marker, so Busy had no age bound. Python's _early_recovery already uses the wait probe.
Red: Windows-only; marker_tests::pid_is_alive_false_for_an_exited_process_whose_exit_code_is_259 (cfg(windows)) needs a native runner.
Green: cargo check --tests --target x86_64-pc-windows-msvc clean on the real marker.rs (std-only harness); Linux marker tests 49 passed.
2026-10-06 04:36:02 -07:00
Teknium
e0cd07ee43 fix(update): tmp litter under our own reused pid number is reclaimed once stale (review PID-reuse-tmp-sweep)
What: the Python and Rust tmp sweeps reclaim a tmp sibling named for our own pid once its mtime is older than 60 s (OWN_PID_TMP_STALE[_SECONDS]); other pids keep the liveness check, and an ASCII pid past u32 is dead in both (Python had kept it since N06; Rust always swept it).
Why: with deterministic container pids a previous holder of our pid number left a tmp that every later run skipped as 'our own in-flight write', so it was never cleaned. Every write of ours finishes far inside 60 s, so a fresh same-pid tmp is still kept.
Red: test_stale_tmp_under_our_own_pid_number_is_reclaimed FAILED and marker_tests::stale_tmp_under_our_own_pid_number_is_swept FAILED on 07c014355b5 (real marker.rs via a std-only harness; the Tauri crate cannot build here without webkit2gtk/dbus).
Green: both pass; test_update_lock.py 59 passed/1 skipped; marker tests 49 passed/2 ignored.
2026-10-06 04:36:02 -07:00
Teknium
b2d3c7d3f0 fix(update): a completion child the job refused holds its own checkout lease (review R5b)
What: every Windows process that joins its ancestor's checkout lock also locks one of 16 lease bytes
past the owner's byte, taken while the owner is seen alive; _try_lock/checkout_lock_held and the Tauri
marker probe treat any held lease as a held lock. The refused completion child still runs (D2: a
committed update never fails) but is no longer unfenced.
Why: a child outside the kill-on-close job outlived a killed owner, whose msvcrt lock died with it,
so a contender acquired while the child still wrote.
Red/green: Windows-native cells (Python owner-killed/unbound child; Rust held lease) need a native run;
POSIX paths unchanged (update_lock suites pass, same failure set as base).
2026-10-06 04:36:02 -07:00
Teknium
3126c6eb34 fix(installer): reject POSIX marker PIDs that narrow to process groups 2026-10-06 04:36:02 -07:00
Teknium
7d257fb619 fix(update): the Tauri updater never reclaims a dead marker while the checkout lock is held (R6)
UpdateMarkerGuard::acquire now takes the install root. Under the marker mutex, a dead marker is
kept (never compare-and-deleted) while the install's checkout kernel lock is held, mirroring
hermes_cli/update_lock.py: checkout_lock_path (common git dir or <root>/.hermes-update.lock) and
checkout_lock_held (POSIX flock probe; Windows LockFileEx on the byte at 1<<20 that msvcrt locks).
The refusal is Busy with a held owner (pid 0), and busy_update_message says the owner exited but a
process it started still holds the checkout. should_heal_self_marker_refusal no longer heals an
exit 2 while the checkout lock is held.
2026-10-06 04:36:02 -07:00
Teknium
8c6e02c1ac fix(bootstrap-installer): marker liveness and overflow parity with Python (access denied = alive; corpus overflow cases)
Windows pid_is_alive treated every OpenProcess failure as dead, including
ERROR_ACCESS_DENIED for an elevated / other-user updater, while Python's
_early_recovery._pid_is_running calls it alive (racing an elevated updater is
worse than postponing). The decision is now the pure
liveness_from_open_error(GetLastError()): access denied = alive, any other
failure = dead. The creation-time probe still returns None for such a pid, so
the combined verdict equals _identity_live: live only within the v1 age
ceiling of started_at.

The new oversized-numeric corpus cases already pass in Rust (u32/u64 parse
failure = malformed, saturating age, +inf ct never matches); unit tests now pin
the started_at = u64::MAX age (0, no overflow) and the +inf ct explicitly.
2026-10-06 04:36:02 -07:00
Teknium
86afd649c3 fix(update): own-incarnation check never probes our own pid
Release now judges the marker by identity, and the identity check sent
os.kill(own_pid, 0) on every release. Our pid is alive by definition; only
the incarnation (5 ms ct) is in question. Same short-circuit in marker.rs.
2026-10-06 04:36:02 -07:00
Teknium
a66193a8c7 fix(update): A7 marker mutex, one incarnation rule, delegate-aware release, checkout custody
R3 / A7 rule 1: every marker decision-and-mutation (reclaim of a dead claim,
adopt + delegate append, release, ownership hand-over) now runs while holding
the kernel lock on the never-deleted sidecar `<marker>.lock` (POSIX flock;
Windows: the file opened share-none, the same lock the PowerShell hand-off
takes), bounded to 10 s. Only the exclusive first publish stays lock-free
(rule 2). Python (update_lock.marker_mutex) and Rust (marker.rs lock_marker)
interoperate on the same lock. A stale reader paused before its unlink can no
longer delete a claim published after its read.

A7 rule 4 / R12: one incarnation rule. Our own pid is ours only within 5 ms of
our creation time; a no-ct claim naming our pid is a previous incarnation
(dead) — in Python (`update_lock.incarnation_live`, exported for the pause
record) and in marker.rs (no more 2 s self tolerance; Electron's v1 own-pid
pre-write is reclaimed and republished as v2; the exit-2 heal requires our
exact incarnation).

A7 rule 5: release is identity-based (`_release_decision` / `release_decision`).
The owner removes its claim regardless of delegate lines unless a LIVE
delegate other than itself exists, which then inherits the claim (rewritten
with the delegate as owner, started_at and run lines kept). A delegate drops
its line while the owner lives, else deletes. This is the native Windows
`orphaned_update` root cause: the hand-off script named `hermes update` as
delegate before it claimed, the child adopted without writing, and its exit
released nothing, leaving '<dead owner> ... delegate:<exited child>'.

Parser (A7 rule 7): lines 4+ are tagged — first well-formed delegate line,
every `run:<id>` line; pid must fit u32. Both consumers run
tests/fixtures/update_marker_corpus.json (judge + release cases).

R2: checkout mutators keep custody. `_git_run` passes the held checkout lock
fd to git (and its hooks); `run_source_script` passes it to the Node build;
`complete_source_checkout` ACQUIRES `UpdateLock(install_root=root)` instead of
claiming only its home's marker; venv_sync's interrupted-update finish acquires
the checkout lock before mutating instead of sampling it and hands the fd to
its completion child; the historical takeover chain passes it down. On Windows
a child of the update joins the lock when its holder record names a live
ancestor (it runs in that owner's kill-on-close job).

R11: a live pre-root-marker claim under <root>/profiles/*/ (the old per-profile
marker) is observed: a new update refuses while one is live and not a partner.

Real-process tests: tests/hermes_cli/test_update_lock_protocol.py (each fails on
6ff4055cd74), corpus consumers in Python and cargo.
2026-10-06 04:36:02 -07:00
Teknium
0a8e2f950a fix(bootstrap-installer): marker claim, parse and liveness follow the LP-LOCK r2 contract
The Rust updater now applies the same marker rules as hermes_cli/update_lock.py:

- A2 parse: one BOM and one trailing CR per line are dropped, spaces/tabs
  trimmed; lines 1-2 must be ASCII digits (fractional/garbage => malformed,
  dead, compare-and-deleted); line 3 is `ct:` or a v1 marker; only line 4
  can be `delegate:<pid> ct:<ct>`.
- A1 liveness: a live pid whose creation time cannot be read gets the v1
  20-minute ceiling instead of an unbounded pass. The ct probe is injected
  so the rule is unit-testable.
- A3 claim: the body is written and fsynced in a `<marker>.<pid>.<n>.tmp`
  sibling and published with an exclusive hard link (fallback: create_new);
  a 0-byte marker younger than 5 s is a claim in flight (live, never deleted).
- M1: an own-pid v1 pre-write (Electron) is CAS-upgraded to v2 with our
  creation time, lines 1-2 byte-identical, and released by complete().
- m8: a marker that cannot be written refuses the update with a clear
  message instead of running unclaimed (AcquireError::{Busy, Unwritable}).
- m10: dead claimants' tmp siblings are swept at claim time.
2026-10-06 04:36:02 -07:00
Teknium
dfb33c107c refactor(bootstrap-installer): move the update marker into marker.rs
Pure move of UpdateMarkerGuard, the marker parse/liveness/compare-and-delete
helpers, the exit-2 self-heal decision and their tests out of update.rs
(2514 lines) into a new marker module. No behaviour change; update.rs now
imports the guard and the heal decision from crate::marker.
2026-10-06 04:36:02 -07:00
Teknium
e267537a75 fix(bootstrap-installer): update marker v2 — root home, creation-time liveness, atomic claim
UpdateMarkerGuard now implements update-marker contract C1:
- marker lives under the profile-tree root (paths::hermes_root), never profiles/<p>
- body pid / started_at / ct:<creation time>; GetProcessTimes on Windows,
  /proc stat on Linux, proc_pidinfo on macOS
- an owner is live iff its pid is alive and its creation time matches (2.0 s);
  no age ceiling for v2 markers, the 20-min ceiling only ages out v1 markers
- claim is create_new + sync_all with one compare-and-delete reclaim of a dead owner
- every delete is compare-and-delete; a live line-4 delegate keeps the marker live

Why: a 20-min ceiling reclaimed live owners mid-update, a reused Windows pid held
the gate, and read-then-write claims let two updaters run at once.
2026-10-06 04:36:02 -07:00
Brooklyn Nicholson
0590ab25fe fix(marker): zombie-aware stale-marker self-heal parity across all readers
Rust live_marker_owner now REMOVES a stale marker (dead pid, past ceiling,
unparseable) on read — previously stale bytes were only ignored, so a crashed
updater whose Drop never ran refused every later acquire until the 20-minute
ceiling (#77259). pid_is_alive gains pid-0 and zombie handling (Linux /proc
state, macOS ps stat), closing the kill(pid,0) false-positive that kept a
dead-but-unreaped updater 'live'.

Python _pid_is_running gains the same zombie awareness via a new
_process_state probe (Linux /proc, macOS ps), which read_live_update inherits
through update_lock._pid_alive — so the CLI gate self-heals a zombie-owned
marker in seconds instead of 20 minutes (#120635, #125932).

Electron readLiveUpdateMarker consults a new posixProcessState probe
(injectable, fail-open to the signal-0 verdict) so the desktop boot gate no
longer parks on a zombie-owned marker.

Rust fix cherry-picked from #77885 (authorship kept); Python and Electron
are new parity work.
2026-09-29 20:57:17 -05:00
webtecnica
0ca79e3602 fix(installer): self-heal stale update marker after crash (#77259) 2026-09-29 20:57:17 -05:00
teknium1
07be7d47ed fix(bootstrap-installer): write the shared receipt schema and resolve the commit without git
Hermes-Setup.exe replaced install.ps1's .hermes-bootstrap-complete with its
own copy: completedAtUnix instead of the completedAt every other writer and
reader uses (install.ps1, Electron main, hermes_cli/source_stamp.py,
scripts/verify-bootstrap-version-stamp.py), and a pinnedCommit from a bare
`git rev-parse HEAD`, which is null on a machine where git exists only as
the PM-staged binary install.ps1 uses.

The receipt now carries completedAt (UTC ISO-8601, milliseconds), and the
commit resolves like install.ps1's Stage-Complete: the pinned commit, else
the checkout HEAD read from its ref files (loose ref or packed-refs), else
the full sha install.ps1 already recorded (read BOM-tolerant).

Fixes #124949
2026-09-27 03:35:26 -07:00
Hermes Agent
5ef8d96554 fix(bootstrap-installer): resolve linux-arm64-unpacked desktop builds (#94703)
resolve_hermes_desktop_exe and the update lock probe only knew the x64
linux-unpacked dir, so on ARM64 Linux the bootstrap installer could not
find the rebuilt app to relaunch and did not wait for the running app's
app.asar before an update. Add linux-arm64-unpacked to both lists.
2026-09-26 20:29:47 -05:00
brooklyn!
e726b79803 fix(desktop): stop macOS launch from showing two Dock icons
The setup launcher had no LSUIElement, and its already-installed hand-off
ran after Tauri/AppKit, whose default activation policy is Regular. That
registered the setup bundle as a second Dock app beside the real desktop.
Hand off before constructing Tauri, and restore Regular activation only
when the installer UI is actually shown.

On macOS the Electron single-instance lock also ran before deep-link
registration. setAsDefaultProtocolClient relaunches the app through Launch
Services, so the loser already had a Dock icon by the time the lock failed
and app.exit(0) ran. Register the protocol first. The lock-losing instance
still hard-exits before ready.

Fixes #73151
2026-09-25 01:01:42 -05:00
ethernet
274b412aa7 fix(bootstrap-installer): never reuse a downloaded install script
Every bootstrap downloads the script fresh, commit pins included; the
on-disk file is only the -File target for this run. Removes the reuse
path, ScriptSource::Cached and the in-place BOM upgrade of old caches.
2026-09-24 19:28:21 -04:00
ethernet
0e7d560d6b fix(bootstrap-installer): never run a stale install script for a branch pin
A 429 from raw.githubusercontent.com made the installer fall back to a
cached pre-migration install-main.ps1. Its repository stage then
fast-forwarded the checkout to live main, whose lock only supports
Python >=3.14, so the old script's 3.11 venv got no core dependencies
and every install died at 'Baseline imports failed'.

The checkout follows the live branch, so the script must too: a failed
refresh of a mutable pin is now fatal (Retry re-downloads). Immutable
commit pins keep permanent cache reuse.
2026-09-24 19:26:32 -04:00
ethernet
7dd4d7556c test(installer): drop retired Windows venv path assertions 2026-09-24 05:10:06 -04:00
ethernet
ca06a8dec9 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/bootstrap-installer/src-tauri/src/update.rs
2026-09-24 05:03:23 -04:00
brooklyn!
edecebf69d fix(bootstrap): show the update child's exit-2 refusal instead of "still running"
Every exit 2 from `hermes update` was reported as "Hermes is still
running. Close all Hermes windows", including when the holder was another
live update. The child prints the specific refusal as a ✗ block right
before exiting; keep a bounded stdout tail and show that block, falling
back to the generic text only when none was captured.

Co-authored-by: RelaxJonh <92573950+RelaxJonh@users.noreply.github.com>
2026-09-24 03:49:06 -05:00
ethernet
b4a294fff9 Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
2026-09-17 13:52:05 -04:00
teknium1
1503d40aba fix(desktop): strip installer escapes from the manifest-failure banner too
Only Log lines were sanitized at the emitter; the manifest-step error string
was still built from the raw child stderr/stdout tail, so a failing
`install.sh --manifest` (print_error writes `${RED}✗${NC}`) put escape bytes
straight into the Setup failure banner. Run stripAnsi / strip_ansi over the
embedded tail on both the Electron and the Tauri bootstrap surfaces (#112675).
2026-09-16 17:50:47 -07:00
teknium1
22b576999f test(installer): fold the strip_ansi cases into one invariant table
Seven near-identical #[test] fns become one table test over the same inputs
(SGR banners, cursor/erase/private modes, OSC with BEL and ST, \r redraws,
sequences cut by the pipe, plain multi-byte text) plus the existing
sanitized_for_ui_only_touches_log_lines. Same coverage, two tests: the
salvage bar is ≤2 invariant tests per fix.

Verified with the function and tests extracted into a standalone
`rustc --test` binary (cargo test on this host fails in libdbus-sys, a
missing system header unrelated to this crate's code).
2026-09-16 17:50:47 -07:00
liuhao1024
74466f03cd fix(installer): strip ANSI escapes from live log events
The macOS setup app drives a shell install script whose output carries
SGR styling, cursor movement, and OSC title commands; the Live output
pane renders log lines as plain text, so those bytes showed up as
mojibake (#112675).

Strip escape sequences at the Rust-to-webview event boundary (bootstrap
and update flows) and collapse carriage-return redraws to the last
visible frame. The on-disk tee keeps the raw bytes for terminal viewing.

Fixes #112675
2026-09-16 17:50:47 -07:00
teknium1
18a14cec0c test(bootstrap-installer): keep two stdio-inheritance invariants, cite #112856
Trim the Windows-only re-exec matrix from four tests to two: one per
launch builder (std = launcher fast path, tokio = `--update` handoff),
each paired with a different captured stream. stdout and stderr flow
through the same handle-inheritance path (GetStdHandle +
SetHandleInformation on the installer side, Stdio::null() on the child),
so the 2x2 product added runtime without adding a distinct invariant.

Also replace the "(issue TBD)" placeholder on open_macos_app_detached
with the issue number.
2026-09-16 16:56:02 -07:00
Sora-bluesky
555ffa3cb5 fix(bootstrap-installer): launched Desktop no longer holds the installer's stdout/stderr pipe
`hermes-setup.exe --update` hands off to the rebuilt Desktop right before it
exits. On Windows, CreateProcess duplicates every inheritable handle of the
installer into the Desktop, and the installer's stdout/stderr are inheritable
pipe handles whenever a shell captures its output. The Desktop therefore held
the pipe's write end and the shell kept waiting until the Desktop exited, even
though the update had succeeded (Windows 11, 2026-09-05: still waiting after
18 minutes, while `> file` returned normally). DETACHED_PROCESS detaches the
console only; Rust's std::process passes bInheritHandles=TRUE and has no
handle list (rust-lang/rust#54760), and setting the child's own stdio to null
does not change which stray handles it inherits (measured: same 4 s stall).

Right before the handoff, clear HANDLE_FLAG_INHERIT on the installer's own
std handles (GetStdHandle + SetHandleInformation, best effort, skipping
missing handles of a GUI-launched installer), through one spawn helper used
by both launch sites; the Desktop's stdio is nulled as well so it never uses
the installer's handles at all. The installer's tracing goes to a file, so
nothing is lost. macOS `open` branches get the same nulling for consistency.

Tests (Windows-only; the crate's CI lane runs on Ubuntu): the test binary
re-executes itself as a helper that launches a 4 s sleeper grandchild through
each real builder and the shared spawn helper; the parent asserts the helper's
piped stdout, and separately stderr, reaches EOF in under 2 s and that the
helper's sentinel line arrived. Without the fix all four cases stall ~4 s;
with it, EOF arrives when the helper exits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:56:02 -07:00
ethernet
5e4a2a3d24 refactor(pm): remove legacy dependency and launch managers
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.

Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.

Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.

Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.

Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
2026-09-12 14:57:38 -04:00
ethernet
8f6d98e4c3 fix activation of devenv, use /usr/bin/env bash everywhere 2026-09-11 11:17:18 -04:00
konsisumer
b3a2065ff3 fix(installer): target Windows updater shim kills 2026-08-26 07:59:01 -07:00
Teknium
bc4ebcf3b0 test(bootstrap): pin the exit-2 self-marker heal contract + on-demand windows-latest Rust lane
The heal decision is extracted into should_heal_self_marker_refusal()
so the contract is testable: heal ONLY on exit 2 + a marker naming this
process. Five tests pin it — self-owned heals, foreign owner (real live
sibling process) never heals, missing/garbage marker never heals,
non-exit-2 never heals, and the full acquire -> refuse -> drop-claim ->
retry-precondition lifecycle with a real UpdateMarkerGuard.

windows-rust-e2e.yml mirrors the wine2e pattern: fires only on
wine2e-rust/** pushes, runs the crate's cargo test --lib on
windows-latest (the shipping platform). The permanent Linux lane stays
authoritative for the unix-gated pipe-drain fixtures.
2026-08-25 21:59:19 -07:00
Teknium
d8d3a219fe docs(bootstrap): correct marker_owned_by_self justification for current live_marker_owner semantics
Main's live_marker_owner has adopted self-owned markers since
160586ff8/dbc2a9c8e (#74761), so the cherry-picked comment's claim that
it 'maps self-ownership to None' is stale. The raw read is still the
right tool — the heal needs the single fact 'does the marker name our
PID' without age/liveness policy folded in.
2026-08-25 21:59:19 -07:00
3x3xX3N0N
26c987c38d fix(install): heal the updater's self-owned-marker refusal loop on Windows
An updater binary spawns 'hermes update' while holding the update marker
with its own PID. A checkout that predates the HERMES_UPDATE_HANDOFF_PID
env fix (8c76fe19f) and the ancestor-pid fallback runs its pre-pull
update_lock.py, reads that marker as a live foreign update, and exits 2
— and the updater deliberately skips its retry for exit 2, so the
refusal loops forever: the update being refused is the one that ships
the fix, and the failure screen's Retry re-enters the same state.

Detect the case with a raw marker read (live_marker_owner deliberately
maps self-ownership to None, so it cannot answer this), drop our own
claim, and retry the child once with the marker absent. The guard
re-removes on Drop (idempotent) and the desktop is already gone at this
point, so nothing races the brief marker-free window.

Fixes #75788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:59:19 -07:00
fangliquan
065c8bd6c0 test(updater): cover handoff marker edge cases 2026-08-20 12:52:21 -05:00
fangliquan
dbc2a9c8e9 fix(updater): preserve holder age across handoffs 2026-08-20 12:51:51 -05:00
Brooklyn Nicholson
24e89883c4 fix(installer): keep the post-EOF wait cancellable
Phase 1 leaves on both pipes reaching EOF without an exit status, so the
final wait was the only thing holding the turn -- and it was a bare
child.wait(), which stranded the cancel channel against a child that
closes its pipes and lingers. Poll cancellation there too.
2026-08-20 12:41:39 -05:00
Brooklyn Nicholson
b94a1613e3 fix(installer): bound the bootstrap installer's pipe drain
`run_script` and `run_streamed` both left their select loop on stdout EOF
and then ran unbounded post-loop drains, so `child.wait()` sat downstream
of a read that a surviving descendant can hold open forever. Pipe EOF is
not the child's to give: the write end is inherited by every descendant
spawned without its own redirection, and `hermes update` deliberately
runs its build steps with stdout inherited. One resident gateway stranded
the whole update, exit code included.

Both now go through one `pump_child`, which takes the exit status from
waiting on the process and bounds the drain from the moment it exits — a
slow child is not a stuck one, so nothing is metered while it runs. An
abandoned drain says so in the log rather than silently truncating.

Cancelling was not an escape hatch either: `start_kill` reaches the
child, not the grandchild with the handle, so the bounded drain is what
lets a cancel return at all.

Same bound `Invoke-HermesStep` grew in windows.ps1 (#90455), and the same
shape as Go's `exec.Cmd.WaitDelay`.
2026-08-20 11:40:45 -05:00
fangliquanflq
d0bb377c96 fix(installer): recover Windows setup when node-deps host exits abruptly (#81390)
* fix(installer): retry abrupt stage host exits

* fix(installer): preserve cancellation across stage retries
2026-08-13 13:38:44 -05:00
Brooklyn Nicholson
e1ccd674c0 fix(desktop): apply the stale-installer marker guard to both hand-offs
Both hand-off sites pre-write the update marker: the in-app Update button
(applyUpdates) and the Windows bootstrap-recovery path
(handOffWindowsBootstrapRecovery). Either one can strand a user on a
pre-#74782 staged installer, and the recovery path is worse — it fires
when the install is already unhealthy, so a refused claim there wedges
the very repair meant to heal it.

Route both through stagedUpdaterSupportsPrewrittenMarker and log the skip
so the reason is visible in desktop.log instead of looking like a missing
write.

Also document on copy_self_to_hermes_home that its --update no-op is what
lets an installer-protocol change strand the entire installed base on a
binary that predates it — the root enabler of this class of bug.
2026-08-01 20:43:08 -05:00
HexLab98
67083c6dde test(installer): pin own-pid adopt for desktop-prewritten update marker
Regression for #74761: acquire must succeed when the marker already
names this process (desktop writeUpdateMarker raced ahead), and still
clean up on Drop.
2026-07-30 17:57:13 +07:00
HexLab98
160586ff8d fix(installer): adopt desktop-prewritten update marker with our own pid
Since #50238 the desktop writes .hermes-update-in-progress with the
spawned updater's PID before UpdateMarkerGuard::acquire runs. Without a
self-PID exclusion, live_marker_owner treated that as a foreign live
owner and every in-app desktop update aborted into a relaunch loop
(#74761). Treat our own PID as adoptable; keep refusing foreign live
updaters.
2026-07-30 17:57:13 +07:00
Brooklyn Nicholson
8c76fe19f8 fix(update): let the GUI updater's hermes update child pass the lock it already holds
The cross-process update lock (fe8e4d93d) made the in-progress marker
mutually exclusive across every update entrypoint — but the Tauri
updater holds that marker for its WHOLE run and then spawns
hermes update as a child stage. The child read the marker, found its
own parent's live pid, refused with exit 2, and the GUI mapped that to
"Hermes is still running. Close all Hermes windows and try the update
again." Retry spawns a fresh updater that deadlocks against itself the
same way, so every GUI-driven update dead-ends on the failure screen
with no winnable retry (observed: three consecutive self-refusals in
bootstrap-installer.log within 90 seconds).

Hand the claim off explicitly: update_child_env exports
HERMES_UPDATE_HANDOFF_PID naming the updater's own pid, and
UpdateLock.acquire treats a live holder matching that pid as the lock
we are already running under — run without claiming, and release
leaves the parent's marker untouched. The env var alone grants
nothing: the pid must also be the live marker owner, so a stale or
forged value cannot bypass the lock, and a dashboard-spawned
hermes update (no handoff env) is still refused exactly as before.
2026-07-30 00:51:33 -05:00
Brooklyn Nicholson
6a444ebec7 fix(installer): release the update marker on success, not just on drop
The updater keeps a Tauri/Cocoa event loop alive while it relaunches the
desktop, and that loop can outlive app.exit(0). Relying on Drop alone
left a *successful* update looking active -- a live pid holding a fresh
marker -- which blocked desktop startup and, now that the marker is also
the cross-process update lock, every subsequent updater until the age
ceiling expired.

Release explicitly once all install-tree mutations are done, before the
relaunch. complete() is idempotent so Drop still covers the failure and
panic paths. Arms a process-exit fallback so a wedged event loop cannot
leave a finished updater lingering as a live pid.

Co-authored-by: nateEc <nateEc@users.noreply.github.com>
2026-07-29 17:48:09 -05:00
Brooklyn Nicholson
38d5f44df1 fix(installer): refuse a second updater instead of clobbering the marker
UpdateMarkerGuard::acquire overwrote the in-progress marker
unconditionally, so a Tauri update launched while a dashboard-spawned
"hermes update" was mid-flight simply took the marker and ran a second
updater over the same checkout. That is the race behind the reported
Windows failure: install-mode bootstrap rewound the tree while the
dashboard's updater was still running npm install against it.

acquire now returns Result and refuses when a live foreign owner holds
the marker, and Drop no longer deletes a marker this process does not
own. Liveness matches the Python and Electron readers of the same file:
dead pid or past the shared age ceiling means stale and reclaimable, so
a crashed updater cannot wedge future updates.

Adds a cfg(unix) libc dependency for the signal-0 liveness probe; the
Windows path uses OpenProcess/GetExitCodeProcess.
2026-07-29 17:46:00 -05:00
Brooklyn Nicholson
ea3be4191e fix(installer): stamp the bootstrap-complete marker from the Rust installer
The macOS launcher fast path gates on hermes_is_installed(), which needs
.hermes-bootstrap-complete next to a built desktop app. Nothing in the Rust
bootstrap pipeline ever wrote that marker -- only install.ps1 did -- so every
reopen of /Applications/Hermes.app re-ran setup instead of launching.

Publish the marker atomically (temp sibling + fsync + rename) because
hermes_is_installed() only checks existence: a torn direct write would arm
the fast path against a half-installed tree. A marker write failure emits
BootstrapEvent::Failed so the installer UI leaves the progress state.

Co-authored-by: giggling-ginger <giggling-ginger@users.noreply.github.com>
2026-07-26 16:02:13 -05:00
Kshitij Kapoor
73b3a8afe3 fix(bootstrap): download timeouts + BOM upgrade for pre-fix cached scripts (#67193 follow-up)
Two residual gaps in the #67369 salvage of #67214, found during review:

1. No timeout on the download client. Since #67369, mutable branch pins
   hit the network on EVERY run, and the stale-cache fallback only fires
   when download() returns Err. A black-holed connection (captive portal,
   hung proxy, dropped packets) never errors, so the whole bootstrap hung
   at resolve() instead of falling back to the cached script. Verified
   live: a request to a non-routable address now errors at the 10s connect
   timeout instead of hanging indefinitely.

2. The UTF-8 BOM was only written inside download(). Immutable commit-pin
   caches take CachePlan::Reuse and are served untouched forever, and the
   stale-fallback path also re-serves the old file - so a BOM-less .ps1
   cached by a pre-fix installer kept reproducing the #67193 ANSI-codepage
   parse failure on every retry (production builds pin BUILD_PIN_COMMIT,
   so this is exactly the retry population). upgrade_cached_script() now
   BOM-upgrades legacy .ps1 caches in place (atomic tmp+rename,
   best-effort, idempotent) on both reuse paths; .sh untouched.
2026-07-19 14:08:48 +05:30
teknium1
8fe9706da8 fix(bootstrap): make read_decoded_line cancel-safe under tokio::select!
The salvaged helper cleared its line buffer on entry. Inside run_script's
tokio::select! loop, a stdout line arrival cancels the in-flight stderr
read (and vice versa); read_until had already consumed bytes into the
buffer, and the next call's clear() silently dropped that partial line.

Keep partially-read bytes across cancellation (clear only after a full
line is decoded) and emit an unterminated final line at EOF instead of
swallowing it. Adds a cancellation regression test (fails against the
clear-on-entry version) and an EOF-tail test.
2026-07-19 01:21:59 -07:00
HexLab98
acee4f25c7 test(bootstrap): cover CP1252 stderr decode and UTF-8 BOM cache writes
Locks the #67193 invariants: localized PowerShell error bytes survive
decode_console_bytes / read_decoded_line (including CP1252-only 0x91/0x92
punctuation), cached .ps1 files get a single UTF-8 BOM for Windows
PowerShell 5.1 -File, .sh stays BOM-less, and mutable branch caches plan
a refresh with stale-cache fallback.
2026-07-19 01:21:59 -07:00
HexLab98
4ce1994159 fix(bootstrap): decode localized PS stderr and refresh mutable install cache
Windows PowerShell 5.1 emits ParserError text in the console ANSI code page,
but the GUI bootstrap aborted BufReader::lines() on the first non-UTF-8 byte
and Retry kept reusing a poisoned install-main.ps1 for branch pins. Decode
child output with a real Windows-1252 fallback, write a UTF-8 BOM on cached
.ps1 files for -File, and refresh mutable branch/tag caches on each run
(immutable SHAs stay cached).

Fixes #67193
2026-07-19 01:21:59 -07:00