Repository navigation
fix(rocm): verify start-ticks in managed service liveness - #709
mikeroySoft wants to merge 1 commit into
Conversation
A managed service record whose recorded PID now belongs to a different process (recycled, e.g. after a reboot) was kept live because the liveness refresh checked only process_is_running. `rocm serve` then found the stale "starting" record, printed "model already being served" and exited 0 without launching anything, so a systemd unit with Restart=on-failure never recovered. Pair each recorded PID with its own start-ticks and classify via rocm_core::identity_state: Matches/Indeterminate count as alive, Recycled/Gone as dead. Legacy records without start-ticks keep plain PID liveness. Signed-off-by: Michael Roy <mike@mikeroysoft.com>
jussielo-amd
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 7580227
This automation never files a GitHub approval, so no approving review will
appear here whatever the outcome — the merge decision stays with a human
reviewer.
Review — needs work
Full review of the whole change (one file, apps/rocm/src/main.rs, +114/-10,
d4e7c751...75802273). No ticket named (PR body: "No upstream ticket ID; no
matching xfail row"). Implements exactly what the PR body describes: pairs each
recorded managed-service PID with its own start-ticks via
rocm_core::identity_state in the liveness-refresh path, matching what the stop
path already does; deliberately leaves managed_service_record_is_in_use (the
prune guard) on bare-PID liveness.
Blocking
-
Two currently-red CI checks on this commit —
E2E tests (Strix Halo, Windows, current)(run 39168498720, job 117895933738) andE2E tests(run
39168498465, job 117898980237).
Windows:FAIL: 'diagnose-fix-path-prepends-with-apostrophe-intact' was expected to pass on this host but FAILED — a regression(145 scenarios, 141
passed). The plainCIworkflow'sE2E testsjob:FAIL: 'dash-gen-tps-expiry-boundary'andFAIL: 'dash-esc-menu-quit-row-confirms-when-serving'(224 scenarios, 220 passed).
Both failing areas are unrelated to this diff (PATH/apostrophe handling in
diagnose; dashboard TPS-expiry timing and TUI quit-confirm) — the change
touches only managed-service PID/liveness classification — andmain's most
recent runs, including this PR's own base commit, are green, so these read as
host/timing flakes rather than regressions this PR caused. Still, a red check
is "not ready" until resolved per this repo's own CI-ownership policy. Please
rerun the two jobs, and escalate if either reproduces.
Confidence 85 · Fix: rerun; escalate with evidence if it reproduces. -
The fixed behavior is user-observable and has no Gherkin coverage, with no
stated justification. Whole change —apps/rocm/src/main.rs.
The bug this PR fixes is externally visible:rocm servewrongly prints
model already being servedand exits 0 without launching anything, and
rocm services listshows a stuckstarting/readystatus for a dead
service. That's exactly the kind of command-output/exit-code/which-
engine-it-selects behavior this repo's AGENTS.md §3 requires a Gherkin
scenario for (tests/e2e-cucumber/features/), or an explicit statement of
why not. I grepped the wholetests/e2e-cucumber/tree forrecycl,
start_ticks, and the "model already being served" text: the only
start_ticksfixtures are inservice_stop_steps.rs, used with matching
ticks to test the stop path — nothing exercises a deliberately-mismatched
("recycled") record throughserve/services list. The repo already has the
primitives to write one cheaply (a planted record pointed at a real decoy
process with wrong ticks, as the new Rust unit tests do, driven through the
actual CLI the wayserve-24andservice-cleanup-07already drive planted
records).
Confidence 85 · Fix: add a scenario (plant astarting/readyrecord whose
PID now belongs to a live decoy process with different start-ticks; run
rocm serve/rocm services list; assert the corrected status/relaunch), or
state in the PR body why this is scenario-exempt.
Non-blocking
managed_service_record_is_in_useis left on bare-PID liveness, and that's
a documented, reproduced bug, not the "harmless" over-reporting the PR body
describes.apps/rocm/src/main.rs:4155(see inline comment).
Open issue #614 reproduces this exact function stranding recycled-PID
records as permanently unprunable (stopandprunedisagree about
liveness) and sharing a misleading "stop it first" hint with genuinely
readyrecords — one incident in #614 actually killed a live server by
following that hint. The code here is unchanged in behavior (just retyped to
readid.pidoff the newProcessIdentity), so this isn't new or worsened
by this PR and isn't a reason to block — but the "harmless" framing in the PR
body doesn't hold up against #614. Worth noting: now that
recorded_service_pidsalready returnsProcessIdentitywith ticks
attached, wiring this guard torecorded_service_process_is_aliveinstead
would be close to a one-line change reusing code this PR already added.
Confidence 85 · Fix: either fix this guard in this PR, or soften the
"harmless" claim and link #614 as the known follow-up.
Decisions for the author
- The "dedupe pid+ticks, prefer the known token" pattern is now implemented
three times, with different tie-break iteration order — non-blocking-improvement.
apps/rocm/src/main.rs:8244(see inline comment) —recorded_service_pids
(this PR, engine-then-supervisor) vs. the pre-existing
terminate_recorded_service_pidsin the same file (supervisor-then-engine)
vs.apps/rocmd/src/service.rs's ownterminate_recorded_service_pids
(supervisor-then-engine). Functionally equivalent today — the tie-break only
matters if both tokens are known and conflict for the same pid, which
nothing currently produces — but worth extracting into onerocm_core
helper so the three can't drift further apart.
Positive signals
- Routing through
rocm_core::identity_stateincidentally also fixes zombie-
process misclassification in the liveness-refresh path: the old
process_is_runningis a barekill(pid, 0), which reads a zombie engine
process as alive;identity_statecatches it. Brings this path in line with
the stop path, which already had that fix. - The four new tests spawn real
sleepchildren and read real
/proc/<pid>/statrather than mocking kernel state. I reverse-applied just
this PR's four production-code hunks (keeping the new tests) and reran them:
liveness_refresh_demotes_record_whose_pid_was_recycledand
liveness_refresh_demotes_recycled_supervisor_when_engine_ticks_unknown
genuinely FAIL against the pre-fix code and pass after, exactly matching the
PR's own before/after table.
Deployment notes
None beyond what the PR body already discloses (records currently stuck
"starting" after a PID-recycling event will reclassify to "stopped" on the
next refresh — the intended fix).
What this covered
Single-pass review of the whole diff (one file, +114/-10,
d4e7c751...75802273), read completely along with its callers/callees
(managed_service_record_is_in_use, settle_pending_stop_key_cleanup,
refresh_managed_service_runtime_liveness, load_managed_services),
crates/rocm-core/src/proc_lifecycle.rs (ProcessIdentity/identity_state),
and apps/rocmd/src/service.rs's parallel stop-path implementation. Single
file at this size didn't warrant fanning out into parallel reviewers.
Verified rather than assumed: ran the new tests on the PR head (pass),
reverse-applied just the production hunks and reran them (2 of 4 fail exactly
as the PR's own table claims), ran this repo's AGENTS.md leak-scan over the
diff (clean), read the full CI status and the two failing jobs' logs, grepped
tests/e2e-cucumber/ for existing coverage, and cross-referenced prior
history (git log -L, PR #499/#667, which this PR's code descends from) and
open-issue search (#614). Did not run cargo clippy/cargo fmt locally — CI
already reports both green for this exact head commit. Not reconciled against
any discussion on this PR — it had zero comments before this review.
| || recorded_service_pids(record) | ||
| .iter() | ||
| .any(|pid| process_is_running(*pid)) | ||
| .any(|id| process_is_running(id.pid)) |
There was a problem hiding this comment.
Non-blocking: this bare-PID check is unchanged in behavior by this PR (by design, per the PR body — the description calls over-reporting "in use" here "harmless"), but it's a documented, reproduced bug: open issue #614 shows it strands recycled-PID records as permanently unprunable (stop and prune disagree about liveness) and shares a misleading "stop it first" hint with genuinely ready records — one incident in #614 actually killed a live server by following that hint.
Not a reason to block this PR — pre-existing, not worsened here — but worth softening the "harmless" framing and linking #614. Also: now that recorded_service_pids already returns ProcessIdentity with ticks attached, wiring this to recorded_service_process_is_alive instead of process_is_running(id.pid) would be close to a one-line change reusing code this PR already added.
(See the full review for context.)
| let Some(pid) = pid.filter(|pid| *pid != 0) else { | ||
| continue; | ||
| }; | ||
| match identities.iter_mut().find(|id| id.pid == pid) { |
There was a problem hiding this comment.
Non-blocking / decision for the author: this "dedupe pid+ticks, prefer the known token" pattern is now implemented three times with a different tie-break iteration order each time:
- here (this PR): engine-then-supervisor
terminate_recorded_service_pidselsewhere in this file (pre-existing): supervisor-then-engineapps/rocmd/src/service.rs's ownterminate_recorded_service_pids(pre-existing): supervisor-then-engine
Functionally equivalent today — the tie-break only matters if both tokens are known and conflict for the same pid, which nothing currently produces — but worth extracting into one rocm_core helper so the three implementations can't drift further apart. Not blocking.
(See the full review for context.)
nowycondro
left a comment
There was a problem hiding this comment.
Automated review (medium/high-recall pass). Two findings posted inline: a tie-break-order inconsistency between the new recorded_service_pids and the pre-existing terminate_recorded_service_pids (this file and apps/rocmd/src/service.rs) that can make the liveness check and the stop path disagree about the same record, and a reuse note about the resulting triplicated merge logic.
| continue; | ||
| }; | ||
| match identities.iter_mut().find(|id| id.pid == pid) { | ||
| Some(existing) => existing.start_ticks = existing.start_ticks.or(ticks), |
There was a problem hiding this comment.
Tie-break order diverges from the stop path, which can make liveness and stop disagree about the same record.
When record.engine_pid and record.supervisor_pid alias the same pid (explicitly anticipated by this function's own doc comment, and by the new test liveness_refresh_demotes_recycled_supervisor_when_engine_ticks_unknown), this merge iterates engine first, so if engine_start_ticks is Some(x) it always wins over supervisor_start_ticks here (the .or() only fills a None, it never overrides an existing Some).
terminate_recorded_service_pids (line ~6942, pre-existing/untouched by this PR) performs the same kind of dedup but iterates supervisor first, so it prefers supervisor_start_ticks in the identical collision case. apps/rocmd/src/service.rs's own terminate_recorded_service_pids also prefers supervisor-first.
Failure scenario: engine_pid/engine_start_ticks can be refreshed independently of supervisor_pid/supervisor_start_ticks (via refresh_from_engine_state, which only touches the engine_* fields and runs before this liveness check on every load_managed_services). If a stale engine_state_path file (not yet overwritten by a freshly-restarted engine) reports a server_pid/ticks pair that now numerically collides with the current supervisor_pid — exactly the kind of PID-recycling coincidence this PR is about — recorded_service_pids will keep the stale/wrong engine ticks and report the pid as Recycled (dead), while terminate_recorded_service_pids would correctly keep the fresh/matching supervisor ticks and report it Matches (alive). That means rocm services list/the liveness refresher could wrongly demote an actually-live, correctly-identified service to stopped, while a stop/restart issued against the very same record would have signalled the real process — the two code paths silently disagreeing about one record.
Suggest extracting one shared merge helper (used by all three call sites) with one canonical tie-break rule, rather than three independent re-implementations with two different orders.
| .collect() | ||
| /// When both name the same pid, the entry keeps whichever start-ticks is known, | ||
| /// so an unknown engine token cannot mask a recycled supervisor pid. | ||
| fn recorded_service_pids(record: &ManagedServiceRecord) -> Vec<rocm_core::ProcessIdentity> { |
There was a problem hiding this comment.
Reuse: this is now the third independent implementation of the same "dedupe engine/supervisor pid+ticks, preferring a known token" logic, alongside terminate_recorded_service_pids in this file (line ~6942) and terminate_recorded_service_pids in apps/rocmd/src/service.rs (line ~226). The three copies differ in iteration order (this one and apps/rocmd's prefer different roles — see the other comment) and in whether the acting process's own pid is excluded. A single shared helper in rocm-core returning the deduped Vec<ProcessIdentity> (with the tie-break rule decided once) would remove the duplication and the risk of the three call sites drifting out of sync, as they already have.
nowycondro
left a comment
There was a problem hiding this comment.
🤖 Automated review (Claude Code) · 7580227
Reviewed the diff in apps/rocm/src/main.rs (+114/-10) that adds recorded_service_pids and routes managed-service liveness refresh through rocm_core::identity_state to verify start-ticks before trusting a recorded PID. Read the full diff via GitHub's PR API (get_diff/get_files), the surrounding callers (refresh_managed_service_runtime_liveness, managed_service_record_is_in_use, load_managed_services), and the analogous pre-existing terminate_recorded_service_pids in this file and in apps/rocmd/src/service.rs. No Rust toolchain available in this sandbox, so I did not build or run the test suite — this is a static read of the diff plus cross-referencing other code paths, not a verified repro.
Findings (posted as inline comments on this PR already; summarized here):
- Non-blocking (medium confidence) — tie-break inconsistency across three duplicate implementations.
apps/rocm/src/main.rs:8235(newrecorded_service_pids) merges engine-then-supervisor, preferring the engine'sstart_tickson a pid collision. The pre-existingterminate_recorded_service_pidsin the same file (~line 6942) and the copy inapps/rocmd/src/service.rsmerge supervisor-then-engine, preferring the supervisor's. Sinceengine_pid/engine_start_tickscan be refreshed independently ofsupervisor_pid/supervisor_start_ticks(viarefresh_from_engine_state), a stale engine-state snapshot that now numerically collides with the current supervisor pid could make the liveness check (this PR) and the stop path (existing code) resolve the same record to opposite verdicts — one says "recycled/dead", the other says "matches/alive". This requires a fairly specific PID-recycling coincidence to trigger, so I'm flagging it non-blocking/worth-a-look rather than blocking, consistent with the existing reviewer's assessment that today nothing actually produces a conflicting pair of known tokens. - Non-blocking (cleanup) — the same pid+start_ticks dedup/merge logic is now implemented three times (this new function, plus the two
terminate_recorded_service_pidscopies) with no shared helper, which is how the tie-break order in finding 1 was able to drift in the first place.
I did not find any new blocking correctness bugs beyond what's noted above. I note the existing jussielo-amd review on this same head commit already raised two blocking items (red CI checks, missing Gherkin coverage for the user-visible behavior change) — I did not re-verify CI status or test coverage myself and defer to that review on those points; my pass focused on the identity/merge logic itself.
Not verified: build/compile, cargo test/cargo clippy, CI status, e2e coverage gaps.
🤖 Generated with Claude Code
|
🤖 Automated review (Claude Code) · 7580227 I reviewed the diff in Two issues worth flagging, both medium confidence:
Non-blocking: I was not able to build or run the test suite in this sandbox (no Rust toolchain / network restrictions), so these findings are based on static reading of the diff and surrounding logic, not verified by execution or by the existing test suite if one covers this path. 🤖 Generated with Claude Code |
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 7580227
Review — needs work
Full review of the whole change (one file, apps/rocm/src/main.rs, +114/-10,
origin/main...75802273, single commit). No ticket named in the PR body. The
change pairs each recorded managed-service PID with its own start-ticks via
rocm_core::identity_state in the liveness-refresh path (settle_pending_stop_key_cleanup,
refresh_managed_service_runtime_liveness), bringing it in line with the
pre-existing stop path (terminate_recorded_service_pids); it deliberately
leaves managed_service_record_is_in_use (the prune guard) on bare-PID
liveness.
Blocking
-
Two CI checks are red on this exact head commit.
E2E tests (Strix Halo, Windows, current)(run 39168498720) andE2E tests(run 39168498465),
re-verified live immediately before writing this review — both still
FAILUREas of now. The failing scenario names (Windows PATH/apostrophe
handling indiagnose; dashboard TPS-expiry timing and a TUI quit-confirm
prompt) are unrelated to this diff, which only touches managed-service
PID/liveness classification, so these likely are flakes rather than a
regression this PR caused — but a red check is not a ready-to-merge state.
Confidence 85 · Fix: rerun the two jobs; escalate if either reproduces on
rerun. -
The user-visible bug this PR fixes has no Gherkin coverage, and the PR
body gives no exemption.apps/rocm/src/main.rs(whole change). The bug
is externally observable:rocm serveprintsmodel already being served
and exits 0 without launching anything, androcm services listshows a
stuck status for a dead service — exactly the class of command-output/exit-
code behavior AGENTS.md §3 requires a scenario for in
tests/e2e-cucumber/features/, or an explicit statement of why not. I
grepped the wholetests/e2e-cucumbertree forstart_ticks,recycl, and
already being served: the onlystart_ticksfixtures are in
service_stop_steps.rs, driven with matching ticks to test the stop path —
nothing exercises a deliberately mismatched ("recycled") record through
serve/services list. The repo already has what's needed to add one
cheaply (plant astarting/readyrecord pointed at a live decoy process
with different start-ticks, then runrocm serve/rocm services list,
following the pattern the new Rust unit tests already use).
Confidence 85 · Fix: add that scenario, or state in the PR body why this
change is scenario-exempt.
Non-blocking
managed_service_record_is_in_useis left on bare-PID liveness, and the
PR body's "harmless" framing doesn't hold up against open issue #614.
apps/rocm/src/main.rs:4155(managed_service_record_is_in_use). Issue
#614 (open) reproduces this exact function stranding recycled-PID records as
permanently unprunable —stopandprunedisagree about liveness — and
documents an incident where following the prune skip-hint ("stop it first")
actually killed a live,readyserver because the hint didn't distinguish a
truly-dead record from a live one. This code is functionally unchanged here
(just retyped to readid.pidoff the newProcessIdentity), so it isn't
new or worsened by this PR — but calling the over-reporting "harmless" in
the PR body conflicts with a reproduced, documented live-server kill. Now
thatrecorded_service_pidsreturnsProcessIdentitywith ticks attached,
wiring this guard to the newrecorded_service_process_is_alivewould reuse
code this PR already adds.
Confidence 85 · Fix: either fix this guard in this PR, or soften the PR
body's "harmless" claim and link #614 as the known follow-up.
Decisions for the author
- The "dedupe pid+ticks, prefer the known token" merge is now implemented
three times, with different tie-break order — non-blocking-improvement.
apps/rocm/src/main.rs:8235(newrecorded_service_pids, engine-then-
supervisor: on a pid collision the already-seen engine entry's ticks win
unless engine's own ticks areNone) vs.apps/rocm/src/main.rs:6942
(pre-existingterminate_recorded_service_pids, supervisor-then-engine) vs.
apps/rocmd/src/service.rs's ownterminate_recorded_service_pids
(supervisor-then-engine). Traced through by hand: today nothing populates
conflicting known tokens for the same pid across engine/supervisor, so the
three disagreeing implementations produce the same answer in practice — but
if a stale engine-state snapshot ever numerically collides with a current
supervisor pid, the liveness check (this PR) and the stop path (existing
code) could resolve the same record to opposite verdicts. Worth extracting
into onerocm_corehelper so the three can't drift further.
Positive signals
- Routing the liveness-refresh path through
rocm_core::identity_state
incidentally also fixes zombie-process misclassification there: the old
process_is_runningis a barekill(pid, 0), which reads a zombie engine
process as alive, whileidentity_stateadditionally checks
process_has_exited. This brings the liveness path in line with the stop
path, which already had that fix. - The four new unit tests spawn real
sleepchildren and read real
/proc/<pid>/statrather than mocking kernel state, and are gated correctly
behind#[cfg(target_os = "linux")]. I could not run them myself (no Rust
toolchain in this environment), but their structure and assertions match the
PR's own stated before/after table and the surrounding non-test code.
Deployment notes
None beyond what the PR body already discloses: records currently stuck
"starting" after a PID-recycling event (e.g. after a reboot) will reclassify
to "stopped" on the next liveness refresh — the intended effect of this fix.
What this covered
Read the whole diff (origin/main...75802273, single file, +114/-10,
single commit) completely, together with its callers/callees in the same
file (managed_service_record_is_in_use, settle_pending_stop_key_cleanup,
refresh_managed_service_runtime_liveness, terminate_recorded_service_pids),
crates/rocm-core/src/proc_lifecycle.rs (ProcessIdentity/IdentityState/
identity_state/terminate_verified) in full, and the parallel
terminate_recorded_service_pids in apps/rocmd/src/service.rs. Verified
independently rather than assumed: fetched the PR head directly by SHA and
confirmed it matches 75802273; pulled live CI status twice (including
immediately before posting) and confirmed the same two checks are still red;
grepped the full tests/e2e-cucumber tree for existing coverage of this
behavior and found none; read AGENTS.md's own Gherkin-scenario rule (§3) in
this repo; fetched and read open issue #614 in full and cross-checked its
reproduction against the current managed_service_record_is_in_use code;
hand-traced the tie-break logic of all three pid/ticks merge implementations
against each other. Did not run: cargo test/cargo clippy/cargo fmt (no
Rust toolchain available in this sandbox) — relying on CI's own report for
build/lint/test results instead. This is a single, small, single-file change;
it didn't warrant fanning out into parallel reviewers. Not reconciled against
any prior review or discussion on this PR — reached independently from the
code, CI, issue tracker, and repo guidance only.
Summary
A managed-service record stays "live" after its process is gone if the kernel has given the recorded PID to an unrelated process. This happens routinely after a reboot, because PIDs restart in the same low range.
rocm servethen reuses the stale record and exits 0, so a systemd unit usingRestart=on-failurenever recovers.terminate_recorded_service_pids) already pairs each PID with its own start-ticks throughrocm_core::identity_state. This applies the same check to liveness.Indeterminate(start-ticks unreadable) still counts as alive, which keeps the existing conservative behaviour.managed_service_record_is_in_use(prune guard) keeps bare PID liveness on purpose. It only blocks a prune, and over-reporting "in use" there is harmless.Evidence
Observed on a host after a reboot. The record had
status: starting,supervisor_pid: 1634,supervisor_start_ticks: 16200. In the new boot, PID 1634 was awireplumberthread with start-ticks 79452:To reproduce outside the host, I put a
startingrecord in an isolatedROCM_CLI_DATA_DIRand pointed its PIDs at a live unrelated process with the wrong start-ticks:rocm services list→Status: 1 startingAfter:
rocm services list→Status: 1 not runningNew Linux-gated unit tests in
apps/rocm/src/main.rs:liveness_refresh_demotes_record_whose_pid_was_recycledliveness_refresh_demotes_recycled_supervisor_when_engine_ticks_unknownliveness_refresh_keeps_record_whose_pid_identity_matches_not_recycledliveness_refresh_keeps_legacy_record_without_start_ticks_not_recycledcargo fmt --all --check,cargo clippy -p rocm --all-targets -- -D warnings, andcargo test -p rocm --all-targets(1250 passed) all pass on this branch.Merge Danger
Door: two-way
It only changes how existing records are classified. Nothing is written beyond the status demotion that a dead-PID record already got.
Blast Radius: narrow
Only records whose recorded start-ticks provably mismatch are affected. Those are now reported
stoppedinstead ofstarting/ready, sorocm serverelaunches instead of reusing them, and daemon recovery sees them as stopped.tests/e2e-cucumber/expectations.tomlfor the fixed ticket ID and removed/narrowed any now-stale xfail rows. (No upstream ticket ID; no matching xfail row.)docs/architecture.md. (N/A: no new subcommand.)