What: the POSIX build launcher (_OWN_GROUP -> _REAP_TREE) no longer starts node in a session of
its own. Node stays in the caller's process group; the launcher holds the checkout lock fd and,
once node exits, kills every remaining descendant: on Linux it is a child subreaper
(PR_SET_CHILD_SUBREAPER) and SIGKILLs + reaps its own children until none is left; elsewhere
(macOS) it records node's descendant tree from ps while node runs and kills the recorded
processes whose start time still matches. SIGINT/SIGTERM/SIGHUP are forwarded to node (killed
10 s later if still running), then the descendants are killed the same way.
Why: start_new_session=True moved node out of the caller's group, so every existing group kill
(update_completion's killpg on Ctrl-C, Desktop's kill(-pid), killHandoffTree) reached only the
launcher; node was orphaned and its npm/esbuild descendants kept writing after the checkout lock
was free (review L1, probe review/lock/custody/p1: grandchild wrote after abort, lock free).
The old killpg after p.wait() also signalled a reaped leader's group (pid-reuse window, review
L6): the launcher now signals only its own unreaped children (Linux) or start-time-verified
pids (ps walk), never a group.
Tests (RED on bbfa23b137d, GREEN here): a group kill of the caller-visible group stops node and
its child; a detached (setsid) grandchild outliving node is killed before the build returns, on
the subreaper and the ps-walk paths; SIGTERM to the launcher exits 143 and leaves no writer.
JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK is only accepted through
JOBOBJECT_EXTENDED_LIMIT_INFORMATION (class 9); class 2 rejects it, so the Windows lane
failed building the control job before reaching the custody launcher. Same shape as
process_identity's job setup; the assertion now carries GetLastError.
Patching update_custody.sys.platform patches the sys module itself, so any lazy
import inside the timed run took its Windows branch: the suite's Popen guard imports
gateway.status there, which then imports msvcrt (red on macOS when that import came
first). A sys proxy scoped to update_custody drives the same Windows branch of run().
The launch-time completion tail now spawns through update_custody.run (checkout
custody), but this file's fixture still patched venv_sync.subprocess.call: the owner
test ran a real source_completion.py child against a scratch checkout (red on macOS)
and the borrowing test's 'no tail ran' assertion was vacuous. Patch the seam
production uses, as test_update_launch_completion.py already does.
The six findings are deliberate boundaries, so each gets an explicit
`# health: allow` with its reason instead of a narrowed catch:
- _old_updater._run_in_custody / update_completion._prepare (HX006): the
waited child is the rest of the committed update (sync + build); it is
supervised and dies with the updater, a timeout would abandon it mid-write.
- _old_updater._checkout_custody (BLE001): calls whatever older release's
update_lock is loaded; no fds means the takeover takes the lock itself.
- update_custody._past_commit (BLE001): fail-closed, unknown = past commit.
- update_custody._held (BLE001): a torn tree's half-written module can raise
anything at import.
- update_lock.process_create_time (BLE001): liveness probe; any psutil failure
(including partial/stub modules) degrades to the stdlib probe instead of
escaping to read_live_update.
- update_lock.read_live_update (BLE001): documented never-raises probe.
Existing `# noqa: BLE001` comments become the health waiver (ruff does not
select BLE here). Comments only; behaviour is unchanged.
The tests added for N06/F12/PID-reuse/readonly-lock used bare write_text/read_text('utf-8'); check-windows-footguns flags those. Bytes I/O, same assertions.
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).
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.
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.
What: _claim_v2 and the dead-partner rewrite in test_delegate_keeps_the_claim_live_after_the_partner_dies write with newline='' (no CRLF translation).
Why: Path.write_text turns \\n into \\r\\n on Windows, but every product writer and the canonical release rewrite emit LF, so the byte-identity assert failed only because of the test's own fixture.
Red: Windows-only (write_text does not translate on Linux); needs the native runner.
Green: test_update_lock.py 58 passed / 1 skipped on Linux; assertion unchanged.
What: on win32, _acquire_checkout releases and refuses (reason: read-only; delete it or fix its permissions) when the lock file only opened read-only; the read-only fallback stays POSIX-only.
Why: Windows children find their owner via that record (_held_by_our_windows_ancestor); without it the parent admitted itself and its completion child was refused (RuntimeError mid-update).
Red: Linux stand-in probe (update_lock.sys.platform=win32, kernel-lock stubs) on 9afee3fc8a1: _acquire_checkout admitted=True with record b'0\\n0\\n'.
Green: same probe -> refused with the read-only reason; native test_read_only_lock_file_is_refused_on_windows (platforms windows) needs a Windows runner.
What: _open_lock_file opens with O_NOFOLLOW, and _acquire_checkout refuses (without unlinking) a lock file that is not a regular single-link file before locking or truncating it.
Why: the holder record is lseek/ftruncate/written into the lock file; a symlink or hard link at that name truncated a file outside the install.
Red: test_checkout_lock_never_writes_through_a_link_to_a_file_outside_the_install[symlink,link] both FAILED on d7dba9fb8d7 (sentinel overwritten).
Green: both pass; test_update_lock.py + _fd_provenance + _protocol 74/74 (incl. the read-only A5 lock-file test).
What: the no-hard-link fallback of _publish_exclusive treats a short write as ENOSPC and, on any write/fsync failure, closes the fd and unlinks the marker only if it is still the inode it created (compared under the marker mutex), then re-raises.
Why: the torn prefix named our live pid, so a process that never acquired blocked every updater for as long as it lived (disk full / EIO on FAT, exFAT, some network mounts).
Red: test_failed_exclusive_create_leaves_no_torn_claim FAILED on 1a545e69af9 (marker left behind).
Green: it passes; test_withdrawing_a_torn_claim_never_deletes_a_replacement (guard) passes before and after; test_update_lock.py 56/56.
What: _sweep_dead_tmp_siblings parses the pid field with _bounded_int (ASCII, u32, overflow-safe) and skips names it cannot attribute.
Why: str.isdigit() accepts Unicode digits like '²'/'①' that int() refuses, so one planted tmp name raised ValueError out of UpdateLock.acquire.
Red: test_tmp_litter_with_a_digit_lookalike_pid_never_breaks_admission[superscript-two,circled-one] both FAILED on 23b8a30cc4a.
Green: both pass; test_claim_publishes_whole_and_reclaims_dead_writers_tmp_files still passes.
What: _parse_marker binds the bounded delegate pid once and uses it, instead of re-parsing the raw token with int().
Why: a delegate line with >4300 leading zeros passed the _bounded_int filter but int() raised ValueError, hiding a live owner from read_live_update and crashing UpdateLock.acquire.
Red: corpus case delegate_pid_leading_zeros_past_int_limit -> ValueError at update_lock.py:594 on b0ced54db07.
Green: 62/62 test_update_marker_corpus.py; Rust corpus test runs the same case.
What: _update_takeover starts update_finish via update_custody.popen_post_commit and _old_updater
starts the takeover via the already-loaded update_lock helpers: created suspended, bound to the
lock owner's kill-on-close job, then resumed (POSIX keeps the lock fd). A refused bind still runs
(post-commit, D2) but is printed/receipted and the child joins the lock holding its own lease (R5b).
Why: both hops were plain unsuspended subprocess runs on Windows, so killing the takeover (or the
old updater) freed the checkout lock while the unbound child kept syncing and building.
Red/green: Windows-native cells added (refused bind -> receipt step / notice; pre-fix none); POSIX
takeover/finish/custody suites pass (144 passed).
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).
What: venv_sync._finish_source_update starts the tail child through update_custody.run(inherit_lock=True):
POSIX keeps the lock fd; Windows creates it suspended and binds it to the lock owner's kill-on-close
job before it runs. A refused bind never runs the child; the launch fails as a failed tail does.
Why: a raw subprocess.call left the child outside any job on Windows, so killing the launching
backend freed the checkout lock while the tail kept building under a second updater.
Red/green: refused custody -> pre-fix 'DID NOT RAISE' (child ran); fixed -> passed. Native Windows cell added.
What: build_prepared_desktop runs npm run build / builder through source_build.run_in_custody.
Why: they used bare run_contained (no lock fd, no Windows job), so a killed owner freed the
checkout while the desktop build kept writing node_modules/release.
Red/green: fake npm recording the lock fd -> pre-fix 'no' (AssertionError); fixed -> 'yes', passed.
What: on POSIX inside an update, contained_command runs the build under a stdlib parent that holds
the lock fd, starts the command as its own process group, and SIGKILLs that group once it exits;
source_build.run_in_custody is the one seam for checkout-writing build commands.
Why: node marks the donated fd close-on-exec, so npm/esbuild under a dead node kept writing with the
checkout lock already free.
Red/green: fake node leaving a fd-less writer -> pre-fix AssertionError (writer ran on); fixed -> passed.
What: the job-joining launcher starts the real command CREATE_SUSPENDED, checks IsProcessInJob
against the update job, and resumes it only then; otherwise kills it and exits 87 (the refusal path).
Why: a redirecting interpreter (Store Python alias) could join while node landed outside the job,
so owner death freed the lock while node wrote. Verification replaces topology guessing.
Red/green: Windows-only cell (SILENT_BREAKAWAY_OK job control) added; needs a native run (pre-fix prints 'built').
What: update_custody.run's in-update (Windows) branch kills the child's tree (taskkill /T /F) on a
timeout, then drains its output for at most 5 s and detaches pipes a survivor still holds.
Why: it killed only git.exe and waited unbounded on git-remote-https, which inherits the pipes.
Red/green: pre-fix run(sh -c 'sleep 20 & sleep 20', timeout=0.5) -> AssertionError (waited); fixed -> passed.
What: delete update_custody.spawn_kwargs (no production caller since #132927 removed the gc fold) and
its helper-only test; a parametrized test drives run_git itself (gc holds the lock fd, a reader not).
Why: the old test exercised only the dead helper and returned early on win32, a false green.
Red/green: mutant 'run_git raises' -> new test 2 failed (old test passed under it); real code -> 8 passed.
#132927 deleted gitlock.consolidate_lazy_fetch_packs, the one updater git
that ran under its own bounded runner with spawn_kwargs custody. Every
updater git now goes through run_git, so the import and the docstring
exception go with it.
CustodyRefused is an OSError, and many readers swallow one (_git_stdout, _capture_head_sha),
so on a refusing host the first refusal could end the update as a misleading downstream
error. update_custody now keeps the run's first refusal (and whether any update child had
run in custody, or the update was past its apply stage), and the command's failure path
(cmd_update; the completion child's _finish) prints it in place of the generic error:
it names `hermes update`, says nothing was changed when no update step had run, and how to
retry. A refused bind and a job that cannot be handed to a build launcher are now receipted
like the refused launcher join; the resume moved to update_lock.resume_suspended_child.
Tests: Linux (the cmd_update failure path after a swallowed refusal, receipt step) and a
Windows live cell (the real readers swallow a real refused job assignment).
The Windows completion child (dependency sync, product builds) was started running and
bound afterwards, so a fast grandchild could escape the update's kill-on-close job, and
a refused bind was only a log line. It is now created suspended, bound, then resumed.
Post-commit a refused bind still runs it (ruling: refusing would fail a committed
update), but never silently: the refusal is printed and recorded as a failed
update_custody step in this run's receipt and in the receipt the child resumes (its
request is rewritten before it runs), so the terminal receipt carries it.
Windows live cell: the real AssignProcessToJobObject refuses an event handle; the
child must not have run at bind time and the step must reach the child's receipt.
The checkout's msvcrt byte lock belongs to the process that took it: an inherited
handle does not keep it, so a writer outside the update's kill-on-close job
outlived a killed owner and wrote the checkout behind a free lock. A refused
bind now kills the still-suspended child and raises CustodyRefused; a refused
launcher join (the Node build) exits before starting node and raises it too; a
job that cannot be created or handed over refuses the same way.
The merge used refs/remotes/upstream/main but the commit counts used the short
upstream/main, which a local branch of that name shadows (refs/heads before
refs/remotes): the sync printed '1 behind' and merged 2 commits. The sync now
resolves refs/remotes/upstream/main^{commit} once and uses that SHA for both
counts (against refs/remotes/origin/main) and the fast-forward.
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.
m3: installer checkouts are partial clones, so a move (reset --hard, merge
--ff-only, checkout) lazily fetched the target's missing objects as a child
holding the checkout lock fd, with the user's credential helper; a
credential-cache daemon it started held the checkout for its lifetime.
Mutators now run with -c credential.helper=, and before a move run_git
fetches what it will read (git diff <HEAD or empty tree> <target>) without
the fd and with the helpers, so a private fork still authenticates.
m4: consolidate_lazy_fetch_packs ran gc --auto with neither the fd nor a
death signal; gc/repack/pack-objects outlived a killed owner and repacked
under the next one. gc (it packs refs) is a local mutator and its bounded
runner gets run_git's custody via update_custody.spawn_kwargs (POSIX: the
lock fd its children inherit). pull is dropped from LOCAL_MUTATORS (unused;
its network half must not hold the fd). The module doc names the exception
and the read-only source_releases/source_check readers.
run_contained merges the build's stderr into the output it drops on success,
so the launcher's 'update custody unavailable' notice was invisible in a
normal update. The launcher now also writes the notice to a report file;
contained_command turns it into a logger.warning (errors.log) and an
update_custody receipt step. The build outcome is unchanged.
venv_sync.prepare_launch and _prepare_borrowed_launch claimed the marker with
UpdateLock() (no install_root), and UpdateLock._claim_marker unlinked a dead
marker without asking whether the checkout lock was held. The claim then
failed acquire_checkout and released its own claim: the killed update's
marker was gone while its completion child still ran, and the Desktop gate
reopened over it.
- _claim_marker refuses as held (pid 0) and keeps the marker when the checkout
lock is held by another process tree (our own hold or an inherited lock fd
may still reclaim).
- UpdateLock(checkout_first=False): install_root names the guarding checkout
without taking its lock first; both venv_sync sites pass install_root=root.
- _lock_holder reports a dead recorded updater over a held lock as held, so
hermes update / launch repair / source completion refusals use the held
wording instead of naming the dead pid (m6).
A venv's Scripts\python.exe is a redirector that starts the base interpreter as its own child
inside a job of its own. That child cannot join the update job once the job holds the updater's
git children (AssignProcessToJobObject: ERROR_ACCESS_DENIED, 'could not join the update job: 5',
every install.ps1 TUI build on the Windows runners), and the redirector keeps the inherited job
handle open, so the job never closed on the owner's death (test_killed_owner_takes_its_node_build_down).
The stdlib launcher now runs on sys._base_executable, and a refused join runs the command outside
the job with a stderr notice instead of failing the build.
The stable-zip identity test injects its git faults at update_custody.run as well: on Windows
inside an update that seam is a suspended Popen bound to the job, not subprocess.run.
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.
R2: hermes_cli/update_custody.run_git is THE updater git runner;
update_cmd._git_run, update_cmd_git._git_run, update_cmd_stash,
update_cmd_check._git, gitlock, update_cmd_zip and the fleet ancestry probe
all go through it.
- every updater git call carries -c gc.autoDetach=false
-c maintenance.auto=false: no detached gc/maintenance child ever forks
under (POSIX) or outlives (Windows) the checkout lock;
- POSIX: only local mutators (reset, checkout, merge, stash, rebase,
update-ref, clean, apply, commit, ...) inherit the lock fd, with
core.fsmonitor=false; fetch/ls-remote/credential and readers run without
it, so a credential-cache daemon never holds the checkout (reviewer: up to
2 h of installer waits). On Linux those fd-less children die with the
updater (PR_SET_PDEATHSIG) instead of rewriting refs under the next owner;
- Windows: inside an update every child is created suspended, assigned to
the owner's kill-on-close job and only then resumed; the Node build
(pm.progress.run_contained) joins it through a stdlib launcher.
The fork sync fast-forwards locally (merge --ff-only
refs/remotes/upstream/main after its fetch) instead of a networked pull
under the mutator fd.
R6: read_live_update/_reclaim_dead no longer delete a dead marker while the
checkout kernel lock is held (a killed updater's completion/build/git still
runs); the reader answers a 'held' UpdateHolder, matching marker.sh's held
verdict, and reclaims only once the tree is gone. update_in_progress and
read_live_update take install_root.
D17: the updater's checkout acquire waits up to 3 s on contention before
reporting busy: a marker.sh/marker.ps1/Python 'held?' probe holds the lock
for microseconds (reviewer: 257/3283 spurious hermes update failures).
D8: _parse_marker bounds pid (u32) / started_at (u64) / delegate pid by
significant digits before int(), so a 5000-digit field is malformed
(dead), never a ValueError out of read_live_update or acquire.
Windows: one kill-on-close job per lock owner (update_tree_job), shared by
every bound child, so a launcher can hand it to a child it cannot see.
A pid or started_at past CPython's 4300-digit int() limit made the Python
reader raise from read_live_update ("never raises") and UpdateLock.acquire,
and a started_at above u64::MAX was valid in Python but malformed in Rust.
New judge cases: huge pid / started_at / delegate pid / ct digit counts,
started_at just past and exactly at u64::MAX, pid with leading zeros; one
release case (oversized pid is kept). Fixture only: every marker reader
(Python, Rust, TS, bash, PowerShell) consumes this file.
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.
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.
One fixture every marker reader (Python, Rust, Electron, Bash, PowerShell)
and releaser runs: bytes, our incarnation, a live process table and the
expected verdict/owner/run (judge) or release action (release). Covers
self-pid reuse, v1/no-ct own pid, delegates, padded/CRLF/BOM lines,
interior whitespace, inaccessible creation time, run lines and the exact
crash-12db native Windows marker.
A fresh pid namespace (bwrap, a container) hands a killed updater's pid to
the next launch about a second after the updater started: inside the 2 s
cross-writer create-time tolerance. The launch then read the dead claim as
its own live one, adopted it (acquired=False) and prepare_launch returned
early as "under the update's claim" -- silently skipping the source-update
tail the kill left owed (test_hostile_commit tail cells, red on the
composite CI only because their timing landed inside the window).
Compare our own pid against our own creation time exactly (3-decimal
marker rounding); other pids keep the 2 s skew window.
cmd_update now locks the checkout it runs from: in tests, this repo,
whose git common dir every parallel test process (and, from a linked
worktree, the live install's `hermes update`) shares, so parallel files
refused each other (`Another Hermes update is already running`). An
autouse fixture points only THIS repo's lock at a per-test file. The
receipt test's fake lock accepts cmd_update's install_root kwarg.
- A2: positional parse (BOM stripped, CRLF accepted, integer pid and
started_at or the marker is malformed = dead; a malformed ct line makes
it v1; only an exact line-4 `delegate:<pid> ct:<n>` counts).
- A1: a live pid whose creation time cannot be read is live only within
the v1 20-minute ceiling (it may be a reused pid).
- A3: claims are published whole (private tmp + exclusive hard link,
exclusive create where links are unsupported); a 0-byte marker younger
than 5 s is a claim in flight and is neither taken over nor deleted.
- A5: an existing lock file we cannot write (left root-owned by
`sudo hermes update`) is opened read-only for the kernel lock instead
of refusing every later update.
- m12: macOS `ps -o lstart` runs with TZ=UTC0 in a minimal env (no DST
skew, no raw environ copy) and is parsed as UTC.
- m10: tmp siblings left by a writer that died are reclaimed at claim.
- No delegate line is written when our own creation time is unknown.
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.
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.
The kill-on-close job that binds the completion child to `hermes update` allowed no breakaway,
so the gateways the completion restarts or resumes (gateway_windows._spawn_detached, which asks
for CREATE_BREAKAWAY_FROM_JOB) fell back into the job and were killed the moment `hermes update`
exited, on success too. Allow BREAKAWAY_OK (not SILENT: ordinary descendants stay bound).
A job that cannot be set up no longer raises out of run_completion after the commit point: it is
logged and the completion runs unbound; the bind sits inside the try whose cleanup kills the
child, so nothing can orphan it.
Windows cell: a real install, a real gateway, a real `hermes update`; the restarted gateway must
still be alive 30 s after the updater exited.