Commit Graph

134 Commits

Author SHA1 Message Date
elvindu
013e94b38a fix(install): name the marker owner as the Desktop hand-off pid
The posix hand-off hands marker line 1 to a custodian subshell
(marker_refresher_start) and then exported HERMES_UPDATE_HANDOFF_PID="$$" —
the hand-off script's own pid.

`hermes update` adopts a live claim only when the owner is its own pid, the
HERMES_UPDATE_HANDOFF_PID value, or an ancestor
(update_lock.UpdateLock._is_partner). The custodian is a *sibling* of the
hand-off script, so it is none of the three and the ancestry walk can never
reach it: every desktop-initiated update refused the claim its own script had
just made and exited 2 ("Another Hermes update is already running ...
process <custodian>"), leaving the Desktop to relaunch and retry forever.

Export $MY_PID instead: the custodian once the handover succeeded, and the
script's own pid when it did not.

Fixes #134268
2026-10-10 10:44:34 -07:00
Maciej Lisiewski
d76cce4f11 ci: apply all automatic linter fixes available 2026-10-08 17:22:54 +02:00
Teknium
808520532c test(desktop-update): kill the macOS app swap at every step and prove recovery
Nothing tested mac_bundle_recover(), the start-of-run repair that finishes or
rolls back a Hermes.app swap a killed hand-off left half done.

posix.sh --self-test-swap-interrupt (body in swap-selftest.sh) runs the REAL
mac_swap on a fixture bundle tree in its own process group. mac_swap now
names its step boundaries (swap_checkpoint start/staged/aside/installed); the
self-test makes the swapper SIGSTOP itself at one of them and SIGKILLs the
whole group there, or resumes it and SIGKILLs it mid-ditto / mid-cleanup rm.
It checks the wreck has the expected shape, runs the REAL mac_bundle_recover,
and asserts the target is the complete old or complete new bundle (whole-tree
checksum, version marker included), no .old/.new/.hermes-update-* is left,
and a second recovery changes and logs nothing. Seven cells: before-stage,
mid-stage, staged, aside, installed, mid-cleanup, complete (control).

The seams are inert in a real run: SWAP_PAUSE_AT is set only inside the
self-test, and MAC_DITTO stays /usr/bin/ditto unless the self-test runs where
ditto is absent (Linux: cp -R).

test_desktop_update_mac_swap_interrupt.py drives the arm under
platforms("posix"): the Linux suite and the macOS lanes of tests-os.yml
(real /usr/bin/ditto, macOS /bin/bash 3.2).
2026-10-07 04:11:50 -07:00
Teknium
d95d5bca1f fix(desktop-update): updater window opens in the Windows 11 25H2 default browser
Get-DefaultBrowserExe read only the legacy UserChoice ProgId, which Windows
11 25H2 Settings no longer updates, so the update hand-off fell back to the
WinForms card (or a stale browser) on machines whose real default is
Chrome/Edge. Read UserChoiceLatest\ProgId first, legacy UserChoice second.

Fixes #108051. Follows #108062 / #108064.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
2026-10-07 02:29:54 -07:00
Teknium
b3f2f9d18d fix(update): owed follow-ups reach the Desktop result through the commit-safe hand-offs
While this branch was in review, #132386 landed on main: a committed
`hermes update` that still owes work prints one whole "Desktop app build
owed:" line and one "Update follow-up '<step>' did not finish:" line per
owed step, and both Desktop hand-offs turn those into a manual outcome
naming the step (never a plain "Update complete."). This branch rewrites
both hand-off scripts, so the rebuild took its versions whole and this
commit carries main's behaviour into them, through this branch's own
post-commit design:

- posix.sh: owed_followup_steps() (unchanged from main: the parser contract
  test runs it), the build-owed and owed-steps checks on a zero exit, each
  setting DONE_NOTE (finish() makes it manual) and an add_warning entry so
  the step also reaches the result's warnings; the Linux relaunch gate now
  appends to DONE_NOTE instead of replacing an owed-step note.
- windows.ps1: the same two checks as Add-Followup -Manual calls before the
  runtime verify, with main's exact [regex]::Matches expression.

Already equivalent here: the mac swap is skipped when the rebuild failed,
and finish() relaunches the kept app for a manual note unless the Linux
gate says skew/manual.

Before: 6 of main's contract tests fail on the rebuilt branch
(test_every_owed_followup_reaches_the_desktop_result x4, the truncated
build-owed case, the parser contract). After: tests/scripts/desktop_update/
plus test_update_post_commit_followups.py pass, 113 passed, 0 failed.
2026-10-06 05:44:21 -07:00
Teknium
299fcf1c69 fix: the POSIX launcher counts the custodian its daemon forked as a started hand-off
Within moments of its claim the daemon hands marker line 1 to the custodian it
forked (fdab580431e). The launcher's start check (contract C2) still waited for
the daemon's own pid, so on a host where the handover beat its next 0.1 s poll
it never matched, and once the daemon finished it reported a running update as
a failed launch (exit 70). `marker_names_handoff` now accepts line 1 naming the
daemon or a process the daemon forked; a grandchild or an unrelated pid still
does not count.

Sabotage A/B (daemon held alive 2 s after the custodian takeover, real
hand-off via the shim-progress harness): old acceptance exits 70, new exits 0
and publishes the stage. Test: the predicate over custodian/daemon/grandchild/
unrelated, red on the previous marker.sh.
2026-10-06 05:44:21 -07:00
Teknium
b2678712f5 fix: an old Desktop never reads a dead owner while the Windows hand-off dies
Same gap as the POSIX hand-off: the -MarkerOp custody watcher took line 1
over only after windows.ps1 exited (WaitForExit, then the marker lock), so
an old packaged Desktop reading in between (lines 1 and 2 only) deleted the
marker while the resumed `hermes update` or a completion survivor still
held the checkout.

Start-MarkerCustodian now names the watcher on line 1 under the marker lock
right after starting it, before any update work, and the hand-off judges
its claim as that watcher from then on ($script:MarkerOwner in
New-MarkerContext): delegate publication, heartbeat and release keep
working. Invoke-MarkerRelease stops the watcher only after the release, so
line 1 stays live until then. Invoke-MarkerCustody keeps a claim that
already names it (judged by the creation time the hand-off recorded) and
falls back to the old takeover only when the handover failed.

Tests (Windows CI lane): the kill cell holds the marker lock across
taskkill /F and runs the shipped legacy reader at once; the pre-publication
kill cell and the A7 race expect the custodian on line 1.
2026-10-06 05:44:21 -07:00
Teknium
4a7c363c0b fix: an old Desktop never reads a dead owner while the POSIX hand-off dies
The previous fix let the line-2 refresher take the marker over only AFTER
the hand-off died: it polls once a second and then waits for the A7 lock,
and until it rewrote line 1 the marker named the dead hand-off. An old
packaged Desktop (judges lines 1 and 2 alone, ignores A7 and the delegate)
reading in that gap deleted the marker and booted its backend while the
`hermes update` the hand-off started, or a completion survivor, still held
the checkout lock -- and the custodian then found no marker to keep.

Now the custodian is named on line 1 BEFORE any update work starts:
marker_refresher_start forks the refresher, then, under A7, rewrites the
claim's line 1 to the refresher's exact pid + creation time and adopts that
identity (MY_PID/MY_CT) for every later marker mutation (delegate, refresh,
release). posix.sh checks its own liveness with $$. The refresher keeps
line 2 young; if the hand-off dies it keeps the marker until the delegate
and the checkout lock are gone, then releases it. The hand-off releases the
marker before it stops the custodian, so line 1 stays live until the
release. If the handover fails (A7 busy), the old takeover-on-death path
still applies.

Test: the orchestrator-killed cell now holds the A7 lock across the SIGKILL
and runs the shipped legacy reader at once (no takeover is possible), then
keeps the post-takeover phases. Red on 5f855a2392c: {'live': None,
'kept': False}. Tests that asserted line 1 == the hand-off pid now expect
the custodian (that assertion encoded the vulnerable state).
2026-10-06 05:44:21 -07:00
Teknium
e8dc3cd63c fix: the hand-off scripts read an unopenable checkout lock as held
marker.sh::checkout_lock_held and marker.ps1::Test-CheckoutLockHeld answered
"not held" when the lock file exists but cannot be opened (root-owned after
`sudo hermes update`, or held open without sharing on Windows), while the
Desktop's own probes (checkout_held / WINDOWS_CHECKOUT_LOCK_PS) answer "held".
The script helper's reclaim then deleted a dead marker the Desktop's probe
keeps. Both scripts now fail closed on every open/probe error except a
missing file: reclaim needs a provably free checkout. Parity tests: the
POSIX probe with perl, python3 and flock(1), plus an end-to-end reclaim; the
Windows reclaim against a lock file held with no sharing (Windows CI lane).
2026-10-06 05:44:21 -07:00
Teknium
d899d5a74b fix: an old Desktop stays parked when the Windows hand-off dies mid-update
Same shape as the POSIX hand-off: windows.ps1 heartbeats line 2 from its
own process, so `taskkill /F` (no finally runs) left line 1 naming a dead
pid while the resumed `hermes update` (the line-4 delegate) or a completion
survivor still held the checkout, and an old packaged Desktop - which judges
lines 1 and 2 alone - deleted the marker and booted its backend.

After its claim the hand-off starts a hidden watcher (-MarkerOp custody
-CustodyOf <pid>, Start-MarkerCustodian). Once the hand-off exits with its
claim still published and the delegate running or the checkout lock held,
the watcher names itself on line 1 under the marker lock, heartbeats line 2
and releases the marker when both end (bounded like the R6 wait).
Invoke-MarkerRelease stops the watcher once the hand-off's own release is
decided. The Windows kill-cell test now also runs the reader be3fd671d7
shipped; that reader runner moves to a helper shared with the POSIX test.
2026-10-06 05:44:21 -07:00
Teknium
e410ffc941 fix: an old Desktop stays parked when the POSIX hand-off dies mid-update
An older packaged Desktop judges the update marker by line 1 (pid alive) and
line 2 (under 20 minutes) alone and unlinks it otherwise, never consulting
the protocol-2 delegate line or the marker helper. The line-2 refresher
exited with the hand-off script, so a SIGKILLed hand-off left line 1 naming
a dead pid while the `hermes update` it started, or a completion survivor,
still held the checkout lock: the old Desktop deleted the marker and booted
its backend into a checkout still being mutated.

The refresher now outlives the hand-off as its custodian (marker_custody):
once the hand-off is gone, if its claim is still published and the delegate
runs or the checkout lock is held, it names itself on line 1 (exact pid +
creation time, under the A7 lock), keeps line 2 young, and releases the
marker only after both are gone (bounded like the R6 wait). The test runs
the reader be3fd671d7 shipped (vendored verbatim under tests/fixtures).
2026-10-06 05:44:21 -07:00
Teknium
ed17c1cea3 fix(desktop): the hand-off waits long enough for a Desktop quit that joins a managed update
The hand-off scripts gave the Desktop a fixed 30 s (windows.ps1) or 60 s
(posix.sh) to exit, but the Desktop's quit can first join a managed SSH update
it is running, which legitimately takes longer. The hand-off then recorded a
failed update although the Desktop exited safely moments later (review H2).

Both twins now share one 150 s ceiling, measured on the clock rather than as a
loop count, and still refuse with "Nothing was changed" if the Desktop is alive
at the ceiling, so nothing is ever replaced under a live Desktop. The ceiling is
overridable through HERMES_UPDATE_DESKTOP_EXIT_SECONDS only so the self-tests
need not sit it out, like the existing pipe-drain and step-idle overrides.

Tests: a Desktop still alive at the ceiling refuses with exit 4 and never runs
the update (red on the start sha, which waited out its hardcoded 60 s); a
Desktop that exits inside the ceiling lets the update run (control).
2026-10-06 05:44:21 -07:00
Teknium
d8b3d565fd refactor(update): update-job.ps1 compiles its job helper in Initialize-HermesUpdateJob
update-job.ps1 was one top-level `if (-not type) { Add-Type ... }` block.
It is now a named function, Initialize-HermesUpdateJob, that returns early
when the type already exists and is called once at the end of the file, so
the dot-source in windows.ps1 behaves exactly as before. The C# body is
unchanged byte for byte.

Checked with PowerShell 7.4 on Linux: dot-sourcing the file twice compiles
the type once and HermesUpdateJob::ProcessIds(0) answers; windows.ps1 still
parses with zero errors. Windows PowerShell 5.1 itself was not run here.

Review 5411223284 (shape: update-job.ps1 top-level code).
2026-10-06 05:44:21 -07:00
Teknium
e13fb204e9 fix(update): with no marker the Desktop gate still asks whether the checkout lock is held
The script helper's `reclaim` answered `absent` the moment the marker file
was missing, before the checkout lock was looked at, and the Desktop gate
(update-marker-gate.ts liveMarkerProbe) returned "nothing runs" for a
missing marker without asking the helper at all. An update that holds the
checkout lock before it publishes its marker, or after it retired it (the
CLI paths of the stacked PRs do both), therefore let the Desktop boot a
backend into a checkout still being written.

- marker.sh marker_op_reclaim_locked / marker-claim.ps1 Invoke-MarkerOp:
  no marker -> `held` when the checkout lock (owner byte + lease bytes on
  Windows) is held, else `absent`, under the same marker lock.
- update-marker-gate.ts (gate-lane file, additive): a missing marker is also
  routed through the helper; only its positive `held` keeps the gate shut
  (blocked screen, Retry, Start anyway as for a dead marker). Any other
  answer, `error` included, proceeds as before, so a broken script never
  blocks a markerless boot.

The gate test "a live owner never reaches the helper; an absent marker
neither" pinned the missing check for the absent half; that half is now the
new "no marker: only held keeps the gate shut" case.

Review 5411223284 (open thread: update-marker-gate.ts ~193 / helper absent).
2026-10-06 05:44:21 -07:00
Teknium
7a8249f35c fix(update): hand-offs report a nonzero exit past the commit point as installed with a follow-up
Both hand-off scripts treated ANY nonzero `hermes update` exit as ok:false
"Update failed". After the commit point `hermes update` can still exit
nonzero by design -- 130 (Ctrl-C after the code moved: receipt outcome
`interrupted`) and 1 (a parked autostash: `partial`) -- or crash after a
committed run (`success`). The Desktop then said the update failed while the
new code was installed (contract C3 / invariant 3).

The scripts now hand the update a correlation id (HERMES_UPDATE_CORRELATION_ID,
already honoured by update_receipt._launcher_correlation_id; a caller's own id
is kept) and, on a nonzero exit other than 2 (refused), read the root's
logs/update_receipts/latest.json. Only THIS run's finalized receipt counts
(matching correlation_id, finished_at set, outcome success|partial|
interrupted); a record reconciled as interrupted keeps finished_at null and
never matches. Then the result is ok:true, manual, with an `update` warning
naming the exit and outcome, and the post-commit steps (Windows: verify,
gateway start --all) still run.

posix.sh also routes its own signal handler through the same judgement: a
HUP/INT/QUIT/TERM that lands after the update committed (exit 0, or a
committed receipt) is an owed `handoff` follow-up, exit 0, not
"hand-off was interrupted".

Tests: posix live cells (RED at start) for interrupted/130, partial/1,
success/1 and an INT delivered while the committed child finishes, plus
negative controls (no receipt, failed, another run's receipt, a reconciled
unfinished record); the Windows twin cells.

review S5
2026-10-06 05:44:21 -07:00
Teknium
98d6720885 fix(update): hand-off results carry the real finish time after the R6 release wait
posix.sh::finish and windows.ps1's finally write the result (finished_at =
now) BEFORE the R6 release wait, which lasts up to 7200 s while a survivor
holds the checkout lock, and before the relaunch. The relaunched Desktop has
no expected run id and handoff-result.ts drops a non-manual result older than
30 min, so a failure or warnings result was silently discarded after a long
wait.

The early write stays (a kill during the wait still leaves a result); when the
wait actually ran, the result is published again after it with the real
finish time, unless something already consumed it. The release records that
it waited (RELEASE_WAITED / $script:MarkerReleaseWaited).

Tests: posix live cell (RED at start: finished_at 3 s before the survivor
let go) and the Windows twin with a timed checkout-lock holder.

review S2
2026-10-06 05:44:21 -07:00
Teknium
6b97fee9c2 fix(update): the Windows hand-off removes its result tmp when publishing fails
Write-Result writes <result>.<pid>.tmp and then Moves/Replaces it over the
result. When both fail (result path locked, a directory, ACLs) the catch only
logged, leaving one tmp file per hand-off beside the result. The tmp is now
removed in a finally (it is gone already after a successful publish), the
same as posix.sh's write_result.

Test (Windows live cell): with the result path occupied by a directory the
hand-off still exits 0 and leaves no .hermes-update-result.json.*.tmp.

review S4
2026-10-06 05:44:21 -07:00
Teknium
684e7d713f fix(update): posix hand-off results stay valid JSON for any control character
json_escape escaped only \ " \n \r \t. Every result/status string it builds
(FINAL_MSG, branch, channel, warnings) can carry text from git/ps/hermes
output; a raw ESC, BEL or any other char below 0x20 made the whole
.hermes-update-result.json invalid, and the Desktop dropped the outcome.

The remaining control chars are now written as \u00XX. Bash 3.2 compatible:
printf -v, no forks; the loop runs only when the string holds a control char.

Test (RED at start): json_escape over chars 1..127 plus UTF-8 and an ANSI
colour sequence round-trips through json.loads, in the C and C.UTF-8 locales.

review S7
2026-10-06 05:44:21 -07:00
Teknium
58512c1671 fix(update): posix marker ops fail closed like marker-claim.ps1 on identity and removal
Two parity gaps between marker.sh and marker-claim.ps1:

- adopt-run (--handoff-run), withdraw's `withdrawn` and `taken` accepted
  J_OWNER_STATE==2, which also covers "alive, creation time unreadable, line 2
  younger than 20 min". marker-claim.ps1 requires Test-ProcessIdentityExact
  (both creation times known, within 2 s). An unreadable ct is no proof the
  pid is still that process; these three now require ident_state 0, and
  `taken` skips our own pid like the ps1.
- reclaim / withdraw printed `reclaimed` / `withdrawn` after an `rm -f` that
  exits 0 even when it cannot unlink; the Desktop then proceeded as if the
  marker were gone. marker_remove checks the marker is gone; else `busy`, the
  ps1's answer when Remove-MarkerLocked fails.

Tests (RED at start): the three identity cases with every foreign ct
unreadable (and the readable control), and a reclaim inside a read-only
home that must answer `busy` and keep the marker.

review S3
2026-10-06 05:44:21 -07:00
Teknium
c8ac525cbf fix(update): Windows marker decisions re-read creation times instead of a pid's cached one
Get-LiveProcessCt caches a pid's creation time for the script's lifetime and
forgets it only when a probe SEES the pid dead. A delegate (the `hermes update`
child) that exits and whose pid is reused between two probes keeps the old
incarnation's time, so the release judged the unrelated new process live and
handed the marker to it (A7 rule 5), parking every later update behind it.

Every judgement made through New-MarkerContext (claim, delegate, heartbeat,
release) and the delegate line Add-MarkerDelegate writes now pass -Fresh,
which re-reads the time. Pollers that only ask "still alive?" (Wait-DesktopExit)
keep the cache.

Test (Windows live cell): a release with the delegate's pid cached at the
recorded ct but really owned by another incarnation deletes the marker instead
of handing it over. marker.sh reads /proc or ps on every call: no cache.

review S6
2026-10-06 05:44:21 -07:00
Teknium
a041e18255 fix(update): the Windows hand-off judges the checkout busy while a leased child holds it
marker.ps1::Test-CheckoutLockHeld probed only the owner byte at 1 MiB. Since R5b
every joiner the kill-on-close job refused also locks one of the 16 lease bytes
after it, and hermes_cli/update_lock.py::_try_lock treats any held lease as a
held lock. With the owner dead and a leased completion child still running, the
hand-off read the checkout as free, so the R6 release wait ended and the
Desktop's reclaim deleted the marker next to a live mutator.

Lock/Unlock now cover 1+16 bytes in one LockFile (it fails if any byte is held).
marker.sh needs no change: POSIX takes the checkout lock with flock on the whole
file and the lease bytes exist only on win32 (_take_lease).

Tests: a live Windows cell holds only a lease byte (1 and 16) and expects
`held`; a static cell, runnable on Linux, pins the probed range (RED at start:
(1048576, 1)).

review S1
2026-10-06 05:44:21 -07:00
Teknium
d549d5fb5c fix(update): a timed-out probe on a host without perl is killed with its whole tree (review desk-noperl-timeout-leak)
What: run_bounded's no-perl branch starts the probe under bash job control (set -m), so it gets its own process group and the timeout's kill -KILL -- -pid reaches its children.
Why: without perl the probe stayed in the caller's group, the group kill failed, and a hung hermes --version / update --help left its python child running.
Red: pre-fix posix.sh fails test_timed_out_probe_is_killed_with_its_whole_process_tree[absent] (grandchild alive after rc=124). Green: both [present] and [absent] pass.
2026-10-06 05:44:21 -07:00
Teknium
3bb4e87775 test(update): runtime unicode docstring matches the ProcessStartInfo UTF-8 capture
Review R8 m7: after the merge with main's 732b63d61a the probe decodes launcher
stdout as UTF-8 through ProcessStartInfo.StandardOutputEncoding and never touches
[Console]::OutputEncoding, so the test docstrings no longer claim the call sites
scope and restore it. runtime.ps1 keeps a one-line #124526 pointer at the
encoding line. Behaviour and assertions are unchanged.
2026-10-06 05:44:21 -07:00
Teknium
450b53fab1 fix(update): require delegate publication before releasing handoff child
(cherry picked from commit c41f84ddffcc36a3a59a975d7f85bf8c5b2f19b3)
(cherry picked from commit c85844ceaca472d1260e9883f3268c01c0c6010f)
2026-10-06 05:44:21 -07:00
Teknium
b5852b3187 fix(desktop): correlate handoff receipts with stable run identity
(cherry picked from commit 87c67de3778735837b73de8d2955f83624b2b449)
(cherry picked from commit bf3ca9f63cead72e51f22d4376abe5b3504d4bf2)
2026-10-06 05:44:21 -07:00
Teknium
b17bcfc704 fix: bash and PowerShell marker readers take the oversized-number corpus cases
The corpus now pins line 2 to digits that fit u64 (any count) and a pid to
digits that fit u32 after leading zeros. bash took a line 2 past u64 as a
far-future start (live); PowerShell rejected any line 2 over 18 digits
(so u64::MAX was malformed) and any pid over 10 characters (so a
zero-padded pid was malformed), and Windows PowerShell's [double]::Parse
throws on a creation time past double's range.

bash compares a 20-digit line 2 as two 10-digit halves (never in signed
shell arithmetic); PowerShell strips leading zeros before the width checks,
compares 20 digits as a decimal, and reads an out-of-range ct as +Infinity,
which never matches a live process. Rewrites carry line 2's exact digits;
only the age arithmetic clamps.

(cherry picked from commit 7208aa4af3c9e355dadee970dddb730fcfeb0301)
2026-10-06 05:44:21 -07:00
Teknium
33cea9a733 fix: bash and PowerShell adopt an old Desktop's launcher bridge by one lineage rule
The two scripts disagreed on an old packaged Desktop's v1 bridge (round 5
D11): bash adopted a live launcher that is our parent but no longer the
Desktop's child when line 2 equals HERMES_UPDATE_STARTED_AT (the Desktop
quit first and the launcher was re-parented); PowerShell refused it, also
accepted a v2/delegate claim naming its parent, required a gone launcher to
be its recorded parent, and Trim()/TryParse'd the env value.

One rule now, as a pure function in both (marker_launcher_rule /
Test-MarkerLauncherRule) over the same facts, plus one env comparison
(plain ASCII digits of line 2's value). tests/scripts/desktop_update/
lineage_rule_cases.py is the shared table; the posix and Windows suites run
it, and a real-process cell per platform covers the divergent row.

(cherry picked from commit aa4b8ce704bc474d539690235c191edee3b637f2)
2026-10-06 05:44:21 -07:00
Teknium
b493b91a33 fix: hand-off probes the checkout lock for two syscalls and refreshes line 2 through the R6 wait
checkout_lock_held took the real checkout lock through flock(1) on a shell
fd and released it only when the shell closed that fd: the hold spanned
flock(1)'s exit, the wait and the close, so a concurrent `hermes update`
acquiring non-blocking was refused 223 of 3157 times in the reviewer's
tight-loop probe (round 5 D17). Now one process (perl, else python3) opens
the file, tries LOCK_EX|LOCK_NB and drops it in the next syscall: 0 of
3387. flock(1) stays the last-resort fallback; no tool still means held.

The old-Electron line-2 refresher ran only while `hermes update` ran, so
an old packaged Desktop could age-delete the marker 20 minutes into the R6
release wait (round 5 D18). The refresher now lives from the claim until
the release is decided (posix.sh), and the Windows heartbeat ticks inside
Invoke-MarkerRelease's wait. Tests shorten the cadence with the test-only
--self-test-refresh-every argument.

(cherry picked from commit 8339b25e9cfbd13f9f3e8f606e703d994b8b5410)
2026-10-06 05:44:21 -07:00
Teknium
b802f677b5 fix: windows hand-off keeps the marker while the checkout lock is held (R6)
Release waits (bounded) while any process still holds the checkout lock's
byte (hermes_cli/update_lock.py takes it with msvcrt.locking at 1 MiB), and
-MarkerOp reclaim answers `held` instead of freeing a dead marker in that
state, so a Desktop gate never opens over a surviving completion. posix.sh
withdraw now keeps a dead adopter's claim (`foreign`), like marker.ps1.

(cherry picked from commit 8a5525d26f036a3c4b5176f11ee4a590c746e2db)
2026-10-06 05:44:21 -07:00
Teknium
a9f8800511 fix(desktop-update): probe .cmd launchers through cmd.exe /d /s /c
Invoke-HermesProbe handed a .cmd launcher to Process.Start with
UseShellExecute=false, letting CreateProcess pick the interpreter and
re-parse the arguments. Run it through ComSpec explicitly like the update
step does, and refuse arguments cmd.exe cannot be made to take literally.

(cherry picked from commit 0b24aab96962535268d71711c0ef564e01ac6e11)
2026-10-06 05:44:21 -07:00
Teknium
8fb5c3b536 fix(desktop-update): A7 marker lock and hand-off protocol 2 on Windows
Every marker mutation (reclaim, adopt, delegate, release/hand-over,
withdraw, line-2 refresh) now happens inside one hold of
[IO.File]::Open("<marker>.lock", OpenOrCreate, ReadWrite, None), retried
for 10 s then BUSY (claim refused). Read->judge->mutate all inside the
hold; the sidecar is never deleted. Replaces the compare-then-replace /
compare-then-delete helpers.

- parser/judge per tests/fixtures/update_marker_corpus.json (trimming,
  BOM/CR, u32 pids, pid 0 dead, delegate/run on any line >= 4, own-pid
  exact incarnation, 2 s tolerance, v1 1200 s ceiling)
- release per corpus: owner deletes or hands the claim to a live
  delegate; a delegate releaser drops its line
- -HandoffRun <id>: adopt only the Desktop bridge carrying run:<id>
- -DesktopPid alone (old packaged Desktop): accept its cmd.exe wrapper's
  v1 overwrite by lineage, never by dropping the check
- -MarkerOp reclaim|withdraw: one verdict line, exit 0 (64 usage)
- `# hermes-handoff-protocol: 2`
- the update child is created suspended in a kill-on-close job, named as
  the delegate under the lock, then resumed: no update instruction runs
  before (or without) the delegate line
- line 2 refreshed every 5 min while the child runs (old readers)
- the job helper moves to update-job.ps1 (windows.ps1 < 2000 lines)

(cherry picked from commit 8bdf467c768da314ce7817f114a6b1b154e11154)
2026-10-06 05:44:21 -07:00
Teknium
a0f2dc903e fix: posix hand-off decides every marker mutation under the A7 lock (protocol 2)
marker.sh (sourced by posix.sh) owns the update marker: corpus parser and
judge, claim, delegate, release and the line-2 refresh, each read -> judge ->
mutate inside one flock(1) / perl Fcntl hold of <marker>.lock (busy 10 s
fails closed). Two live claimants of one dead marker no longer both win (R3).

Hand-off protocol 2 (`# hermes-handoff-protocol: 2`): --handoff-run R adopts
only the Desktop's live bridge carrying run:R; an older Desktop's bridge (its
launcher's pid, v1) is adopted by lineage -- the named pid is our parent and
the Desktop's child, or carries HERMES_UPDATE_STARTED_AT -- never by dropping
the check (R4). --marker-op reclaim|withdraw gives the Desktop one verdict
line; it never mutates the marker itself.

Release waits while any survivor still holds the checkout lock and the
reclaim helper answers `held` for a dead marker in that state (R6). The
update child is gated on a go-file created only after the delegate line is
published (pre-publication crash window), run_bounded kills the probe's
whole process group, and line 2 is refreshed every 5 min while ours so an
older Desktop's 20-minute age ceiling never deletes a live claim.

(cherry picked from commit f16dcc5583df8294cea96e6941cfdf5d4a7c8078)
2026-10-06 05:44:21 -07:00
Teknium
2245190411 fix: posix hand-off names its hermes update child as the marker delegate at spawn
A daemon killed in the second between spawning `hermes update` and that
child taking the update lock left a marker whose only identity was the dead
script: every reader judged it DEAD and a second update could start over the
first. The child now runs asynchronously just long enough for its pid to be
written as line 4 (`delegate:<pid> ct:<ct>`, compare-and-swap on our own
lines 1-3), then is waited on; HUP/INT/QUIT that land meanwhile are handled
after it exits, exactly as with the former foreground child.

(cherry picked from commit a09d2fb7cac0d730b491560ea0f970e5d227421e)
2026-10-06 05:44:21 -07:00
Teknium
4729c737e5 fix: windows hand-off reads other users' creation times and never fails a committed update
- marker helpers move to scripts/desktop-update/marker.ps1, dot-sourced
  first (windows.ps1 2113 -> 1930 lines); a hand-off that cannot load it
  exits 3 having changed nothing. It ships in the checkout next to
  windows.ps1 and runtime.ps1, which is where the Desktop runs them from.
- creation time comes from Win32_Process.CreationDate (limited query
  rights), cached per pid while it lives: Get-Process .StartTime is access
  denied for SYSTEM/elevated/other-user pids under PowerShell 5.1, so a
  pid reused by a service after a reboot read as a live owner forever.
  Alive with an unreadable time is live only inside the 20-minute ceiling.
- -DesktopPid runs adopt the Desktop's live bridge claim only; a refused
  run exits 2 without a result file and only reopens a Desktop that is gone.
- positional parse identical to the other readers; claims publish the
  complete body with an exclusive hard link (rename fallback); a fresh
  empty marker is a claim in flight; the delegate line always carries ct.
- a follow-up step whose process tree cannot be quiesced after the commit
  point is ok:true + a manual warning (no relaunch, marker kept live),
  not "still on the previous version".
- the interrupted post-update tail is always a manual warning: re-running
  hermes update is the only remedy, so the copy says exactly that.
- the launcher probe bound is a -ProbeTimeoutSeconds parameter (default
  60), not the HERMES_UPDATE_PROBE_TIMEOUT_SECONDS environment variable.

(cherry picked from commit 65663dc6a64f942c6385b7de3ba13cdecd393387)
2026-10-06 05:44:21 -07:00
Teknium
073eb00f1d fix: posix hand-off adopts only under a Desktop pid and parses markers like every reader
Contract amendments A1-A4, posix side:
- --desktop-pid runs only adopt that Desktop's live bridge claim; with no
  bridge (the Desktop gave up after its 20 s wait) or anyone else's marker
  the script exits 2 without claiming, running, or writing a result
- a refused hand-off (exit 2) never writes .hermes-update-result.json; the
  other update owns the outcome, and a stale "still on the previous
  version" dialog could otherwise follow a successful CLI update
- positional parse: BOM/CRLF accepted, non-integer line 2 = malformed
  (dead), bad ct line = v1, line 4 only as "delegate:<pid> ct:<ct>"
- a live pid whose creation time cannot be read is live only inside the
  20-minute ceiling (unknown never means forever)
- an empty marker younger than 5 s is a claim in flight
- macOS ps lstart is rendered and parsed with TZ=UTC0 (DST-safe)

(cherry picked from commit 9d8b58063f0c2d8de6bc37b615e6e49f1abb7549)
2026-10-06 05:44:21 -07:00
Teknium
cb8451baa5 fix: windows hand-off claims the update marker first and never fails a committed update
windows.ps1 implements update-marker contract C1 v2 and the C3 outcome
vocabulary on the Windows Desktop hand-off:

- The marker claim is the script's first action (before any Add-Type, UI
  or probe): exclusive create (tmp + File.Move, never overwrites) with a
  v2 body "<pid>\n<started_at>\nct:<creation time>\n". A live marker owned
  by exactly -DesktopPid is adopted by compare-and-swap keeping its
  started_at; any other live owner refuses the run (exit 2, marker
  untouched, "Nothing was changed"); a dead owner (pid gone, creation time
  mismatch = pid reuse, v1 older than 20 min) is compare-and-deleted and
  the claim retried.
- Release is compare-and-delete on lines 1-3 and keeps the marker while a
  line-4 delegate is alive. When a stalled tree cannot be quiesced (exit 7)
  the marker gains a delegate line naming a surviving job member
  (JobObjectBasicProcessIdList), so it stays live exactly as long as the
  tree does instead of naming the dying script.
- After `hermes update` exits 0 (or the 124 banner remap) the result is
  ok:true, exit_code 0: verify failure (was exit 8), legacy rebuild
  failure (was exit 6), a gateway restart miss, an interrupted post-banner
  phase and any unexpected throw become `warnings` + manual, with a
  "Hermes was updated, but ..." message; gateway start --all still runs
  after a failed verify/rebuild.
- The idle watchdog counts job CPU/IO accounting deltas as progress, so a
  pipe-silent but busy step (download, extraction) is not killed.
- Launcher probes (--print-runtime-command, --version, update --help) run
  with a 60 s timeout and kill their tree on expiry.
- Desktop exit wait checks pid + creation time pinned at start, and a
  still-open Desktop is never relaunched over (exit 4).
- The result file is written atomically, carries started_at (marker line
  2) and warnings, and is no longer deleted at hand-off start.
- Stop-UiServer only removes its own profile dir and dirs whose pid is not
  running (TEMP is shared across HERMES_HOMEs).

(cherry picked from commit af178fd695d7f94bf584bfabe9748cbb752fea13)
2026-10-06 05:44:21 -07:00
Teknium
b54d069815 fix: posix desktop hand-off claims the update marker first and never fails a committed update
posix.sh now implements the update-marker contract (C1/C2) as a lock:
- the daemon claims `.hermes-update-in-progress` as its first action with a v2
  body (pid, started_at, `ct:` creation time) via an atomic no-replace create
  (link(2)); a LIVE foreign owner refuses with exit 2 regardless of age, a
  marker owned by --desktop-pid is adopted keeping its started_at, a dead one
  is compare-and-deleted; liveness honours a line-4 `delegate:` (the running
  `hermes update`), so an orphaned update is never reclaimed;
- the launcher waits for the daemon's claim and exits non-zero when it never
  lands, so the Desktop does not quit into nothing;
- release is compare-and-delete and keeps a marker whose delegate is alive.

Outcomes follow C3: a legacy rebuild failure, a mac bundle stage/swap failure
or a relaunch gate after `hermes update` exited 0 is ok:true + warnings (no
more exit 6 / exit 7 "still on the previous version"). The result carries
started_at and warnings and is written via a per-pid tmp + rename.

Crash safety: start-of-run recovery restores an interrupted .app swap from its
aside copy (both swappers' suffixes) and a killed TCC heal backup, and sweeps
UI/probe litter of dead pids; the two swap renames are a signal-deferred
critical section. `hermes desktop`'s Python swappers recover the same state
instead of deleting the only copy first.

Also: probes are bounded (60 s), `hermes update` streams into the log, the
update child gets default TERM and the daemon default HUP (no SIG_IGN leak into
gateways or the relaunched app), the relaunched Linux app drops the update env,
the Desktop wait is zombie/pid-reuse aware and never relaunches over a live
app, serve-ui.py exits with its orchestrator, and the job section is parsed as
one brace group so `hermes update` rewriting posix.sh cannot shift the rest.

(cherry picked from commit c7dd9fab00b8af09321ec982d07a62f03351355d)
2026-10-06 05:44:21 -07:00
Teknium
260cef995a fix(update): every owed follow-up reaches the Desktop hand-off result
A committed update exits 0 and prints one whole "Update follow-up '<step>'
did not finish:" line per owed step (update_receipt.record_followup). The
hand-offs only reacted to the Desktop build line, so a gateway proven to
run old code (gateway_restart), a refused Windows resume, a lost
completion or a failed channel adoption ended as a plain
"Update complete.".

posix.sh collects the distinct owed steps from that line
(owed_followup_steps) and turns them into the manual result message
(DONE_NOTE, the only channel the main-based script has); a gateway_restart
adds the `hermes gateway restart` hint. windows.ps1 does the same through
its manual outcome. With #132354's add_warning protocol the same
owed_followup_steps output feeds one add_warning per step.

record_followup's docstring names the line as a protocol; a contract test
runs the real posix.sh parser and the windows.ps1 regex against it.

Review: kshitijk4poor 5411222454 regression 1 / open thread F6.
2026-10-06 03:44:49 -07:00
Teknium
e28c05e4b0 fix(update): an unbuilt Desktop app is reported by one whole line the hand-offs match (review F6-desktop-build-debt-grep)
What: build_update_products acquires npm inside the "Node dependencies" attempt and, whenever the
desktop product was requested but not built, prints `Desktop app build owed: <step> failed`.
posix.sh matches that line instead of the truncated follow-up text; windows.ps1 now projects it as
a manual outcome (it had no projection of the current follow-up at all).
Why: the 500-char follow-up leads with whichever product failed first, and an npm acquisition
failure escaped un-named, so a committed update whose Desktop was not rebuilt said "Update complete."
Red: pre-fix source_build.py -> test_unbuilt_desktop_is_named_on_one_whole_line[both] fail; pre-fix posix.sh -> truncated-followup hand-off test reports manual=false.
Green: test_source_build.py 15 passed; test_desktop_update_gateway_flag.py 3 passed. windows.ps1 needs a native run.
2026-10-06 03:44:49 -07:00
Teknium
630b1bce80 fix(update): a conflicted autostash is owed to the user, never a plain success
Contract C3 keeps post-commit step failures at exit 0, but the parked stash is not a
step anything retries: the receipt records a user_action, finalizes partial and
hermes update exits 1 (#122557). The Desktop handoff reports an owed app rebuild as
an action ("could not be rebuilt ... hermes desktop --force-build"), and the pm e2e
follows A6 (dependency sync after the tree moved is an owed follow-up).
2026-10-06 03:44:49 -07:00
Brooklyn Nicholson
732b63d61a fix(install): decode launcher path captures as UTF-8 and refuse a HermesHome inside InstallDir
Extends #124526's class fix to the two remaining native captures that can
carry a profile path, and closes the secondary issue from the report:

- scripts/desktop-update/runtime.ps1: the launcher --print-runtime-command
  JSON and the legacy user-bin --version 'Install directory:' capture are
  both scoped to [Console]::OutputEncoding = UTF-8 and restored in finally,
  matching install.ps1's Invoke-Native -Utf8Output and every other consumer
  of the boundary (windows_ssh_runtime, the Electron updater, the Rust
  bootstrap all decode UTF-8). PS 5.1's OEM decode mojibaked the reported
  install root so the legacy identity check never matched on a non-ASCII
  profile.
- scripts/install.ps1: -HermesHome equal to or inside -InstallDir puts the
  pm tool store inside the checkout; the repository stage's occupied-
  directory preflight then refuses every retry after the first run
  populated <InstallDir>\tools. Refused up front unless HERMES_RUNTIME_DIR
  parks the store elsewhere.

Tests: tests/scripts/desktop_update/test_desktop_update_runtime_unicode.py
(real published launcher, non-ASCII profile, CP936/437/65001, encoding
restore) and scripts/tests/test-install-ps1-store-guard.ps1 (real installer
subprocess through -ShowResolvedPaths: refusal, escape hatch, defaults).

Co-authored-by: 择梦舟 <158067205+dreamfarer-space@users.noreply.github.com>
2026-10-04 13:23:25 -05:00
teknium1
7f7e1ab402 fix(desktop-update): any failure after the banner keeps a killed update failed
The 124 remap was vetoed only by "Update not complete", but the restart
and verify phase prints other failure lines after "Update complete!":
"Update incomplete — …", "✗ <unit> failed to come back/stay running
after restart", "verification incomplete". Any of them now keeps the
watchdog kill a failure. Only text after the last banner is inspected,
and ✗ is matched as \u2717 because Windows PowerShell reads the BOM-less
script as ANSI.

Reported by @unsupportedpastels in review.
2026-10-01 22:35:29 -07:00
teknium1
91e8f7595d refactor(desktop-update): keep only the reachable 124-after-completion remap
The Windows hand-off's idle watchdog is the only producer of exit 124 on
main (posix.sh has no watchdog and `hermes update` never exits 124), so
the posix remap was dead and goes. The retry-state helper and its
self-test arm collapse into one Resolve-HermesUpdateOutcome check, now
proved end to end through the real hand-off instead: a fake update that
prints its banner and parks silently past a 3 s ceiling surfaces as a
verified success with the gateway fleet restored.

The remap also refuses a run whose output already carries the
"Update not complete" verdict, which supersedes the banner.
2026-10-01 22:35:29 -07:00
Brooklyn Nicholson
1b0d8aa3d5 fix(desktop-update): completed update killed with exit 124 is terminal, never re-run (#96205)
Port of #96340 (Finn763) onto the current legacy-scoped retry gates,
narrowed to the watchdog sentinel only: exit 124 + the "Update complete!"
marker maps to success so the hand-off verifies and relaunches the new
build instead of re-running the completed update (the 70+ minute popup
retry storm). A plain exit 1 after the marker is the stale-gateway
verdict (hermes_cli/update_receipt.py) and must keep failing.

As first written this also added a posix.sh gate and a retry-state
helper with a -SelfTestRetryState arm; the next commit drops the posix
gate (nothing there exits 124) and replaces the helper and self-test
with one end-to-end test through the real hand-off.

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
2026-10-01 22:35:29 -07:00
Brooklyn Nicholson
d06a3b8a54 fix(desktop-update): keep console selection from stalling the Windows hand-off
conhost blocks every write to a console while a selection is active. The
hand-off replays buffered child output through Write-HandoffLog after
`hermes update` exits, so a selection in a visible hand-off console held the
result, marker cleanup and relaunch until the user pressed Esc (#103222).

Turn QuickEdit off on the console input buffer for the run (restored on
exit), and skip the console echo while a selection is in progress. The log
file still gets every line.
2026-09-26 21:43:20 -05:00
Brooklyn Nicholson
21edd6d5ae fix(desktop): stop prescribing repair and antivirus review for an unverified update
A failed receipt check after a zero-exit update means the updated Desktop build
could not be read, not that the install is damaged. The old copy told users to
repair the installation and review antivirus quarantine, which is destructive
advice for a healthy install (#107685). Say what happened and give the one
non-destructive recovery step.
2026-09-26 21:43:07 -05:00
Brooklyn Nicholson
5a4ff55f43 fix(desktop-update): accept arch-suffixed unpacked dirs in the linux relaunch gate (#94703)
electron-builder names the unpacked dir linux-unpacked on x86_64 but
linux-<arch>-unpacked on every other arch (linux-arm64-unpacked is what
ARM ships). The gate hardcoded the x86_64 name, so a healthy ARM install
false-gated as "skew" on EVERY update, telling the user to reinstall an
app that was already correct. The ostree/symlink half was fixed earlier
(d3b090a3); this lands the remaining arch-dir half.

Resolve the unpacked dir the running binary actually lives in by scanning
the release dir's linux*-unpacked candidates (canonicalised both sides
before the compare, so the symlink fix's semantics are preserved, with a
first-found fallback so foreign targets keep gating as skew.

Tests drive the real --self-test-gate entry point; on BSD-readlink hosts
a PATH shim provides GNU  so the gate logic runs everywhere
(the existing linux_only symlink matrix covers -dependent paths).

Co-authored-by: C-Est-Dept <cestdept@example.com>
Co-authored-by: Sahilvishnaliya <sahil@example.com>
EOF
)
2026-09-26 20:29:47 -05:00
ethernet
a3456765ed fix(desktop-update): give Windows update steps NUL stdin, not the hand-off console
Steps inherited the hand-off console's stdin, so any step that asks a
question blocked forever. Its prompt went to the captured stdout, which
is shown only after the step exits. `gateway start --all` did exactly
this: it saw an interactive console and asked "Install it now so the
gateway starts on login?". The update stopped after `hermes update exit
code: 0` and never relaunched the app.

Steps now read NUL. Prompts see a non-interactive stdin and take their
defaults. The working-directory self-test also checks that a step's
stdin is not a console, and a new test runs it under a real console.
2026-09-24 23:49:18 -04:00
ethernet
cb7b18431a Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/src/app/settings/connections-registry.tsx
#	scripts/install.ps1
#	scripts/install.sh
#	tests/hermes_cli/test_update_autostash.py
2026-09-24 03:48:31 -04:00
brooklyn!
14a8a771de fix(desktop-update): stop launching a Chrome instance for the macOS update shim
Each update started the user's Chrome binary with its own --user-data-dir,
a second instance of the same bundle that the Dock records as a new
recent-app tile. On macOS the outcome now goes through the existing
notification + next-boot result dialog.

Fixes #96374
2026-09-24 02:36:14 -05:00