Repository navigation
Conversation
On Windows, spawn_managed_engine_child launched the detached __engine-serve-http child with spawn_detached_no_inherit, which leaves its stdout/stderr unattached to anything. Linux attaches them to the service log via attach_background_stdio, so a crash's reason lands there; on Windows it reached nothing. A dying engine therefore left the log silent past its last explicit progress line, and both the managed and --verbose/--foreground serve paths only ever saw the child's pid disappear, which they treated as a benign stop: "server process exited" (or an unreadable "starting" status), exit 0, and the engine's own state file stuck at whatever it last wrote. Fix the Windows spawn (both the launch and the restart path) to attach the service log via the existing spawn_hidden_console_with_log primitive, mirroring Unix. As a platform-generic backstop — covering a gap in the attach, and any engine code path that returns an error without updating its own state file — run_builtin_engine_serve_http now logs the full error chain to the service log and this process's own structured log, and marks the engine state file `failed`, on any error exit. Neither platform's rocm serve actually inspected the child's fate after spawning it, though: a readiness wait that times out is indistinguishable from one that will never succeed because the child already died, and the attached log-streaming loop treated every self-exit as a graceful stop. Add finalize_managed_launch_readiness (background path) and an equivalent check in run_attached_service's ServerExited arm: both now confirm the child is still alive before accepting a non-Serving readiness result, and fail the launch with a log-tail-carrying error (and a failed engine state file) when it is not. A readiness timeout on a still-alive child keeps reporting its previous status with exit 0 unchanged, since a large model can legitimately take longer than the readiness budget. pid_is_running is injected into finalize_managed_launch_readiness so the dead-child branch is unit-testable without spawning and killing a real process, the same seam autostart_spawn_in_flight already uses nearby for an identical reason. New tests cover both liveness outcomes plus the untouched Serving-trusts-itself case, and assert the failure message and the resulting process exit code together, reading back the on-disk manifest and engine state file to confirm both land on `failed`. Not covered by a new e2e scenario: exercising this live (rather than via the injected-liveness unit tests) needs a real __engine-serve-http spawn that survives past both platforms' early-startup liveness checks before dying, without GPU hardware. The one close precedent in this codebase (a scripted engine death at startup) needed its own preflight- and engine-preparation- bypass plumbing built up over several review rounds; replicating an equivalent for a later-than-startup death is a separate, larger effort left to a follow-up rather than folded into this fix. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
…(ROCMAI-541) Round-2 review fixes on top of 5b4fe55: B1 — the Unix liveness check was inert: the managed engine is a direct child (setsid only, no double-fork), and process_is_running's kill(pid, 0) probe succeeds for a zombie this process has not reaped yet. A dead-but-unreaped child was therefore still reported "running" on Linux, reproducing the original symptom despite the round-1 fix. Added rocm_core::ManagedChildLiveness, which retains the spawned std::process::Child on Unix and calls try_wait() (reaping it) instead of signal-probing; Windows keeps using process_is_running, which already reads the real exit code via OpenProcess/GetExitCodeProcess and needs no parent-side reaping. Threaded through finalize_managed_launch_readiness (launch path), run_attached_service and stream_attached_logs(_no_tty) (attached path), and the restart path's early-death check and (new) record_restart_readiness liveness gate. B2 — AttachOutcome::Stop only ever fires from this attached session's own keystroke reader; a stop triggered by `rocm services stop` from another shell, `rocm dash`, or rocmd's restart-recovery kills the watched pid directly, landing in ServerExited instead — which the round-1 fix treated unconditionally as a crash. This made every piped/CI `serve --verbose` (no keystroke capture at all) fail on an ordinary stop. ServerExited now checks the manifest's stop_requested_unix_ms marker (set before any such stop signals anything) and status=="stopped" before concluding a crash; when either is present it keeps the old benign-stop behavior (exit 0, no `failed` stamp). Fixing this also surfaced a related gap: the crash branch only marked the engine state file `failed`, never the manifest itself: now does both. B3 — added e2e coverage per AGENTS.md's Gherkin requirement for exit code / output changes: serve-25/26 in model_serving.feature exercise a real managed child dying after startup, and a real external stop in the same window, via a new scripted-engine test hook (ROCM_E2E_SCRIPTED_ENGINE[_DEATH_MS], e2e-test-hooks-gated) that stands in for the real engine dispatch and needs no GPU. Confirmed serve-25 hangs (the exact ROCMAI-541 symptom) when the B1 fix is reverted. N1 — removed the vacuous `exit_code_for(...) == ExitCode::FAILURE` assertion from finalize_managed_launch_readiness's dead-child test: it could not fail regardless of correctness (every non-FixExitCode/ ClapExitCode error maps to FAILURE there), and the test never goes through the real run()/exit_code_for call chain it implied. N2 — extended the same liveness fix to the restart path: record_restart_readiness now takes a liveness check and fails closed (marking both manifest and engine state `failed`) instead of recording `starting` forever when a restarted engine dies during its own readiness wait; the Unix early-death branch now also marks the engine state file, matching the launch path. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
…(ROCMAI-541) Round-3 review fixes on top of 0df6166: B1 — managed_service_stop_was_requested was only ever consulted on the attached (--verbose/--foreground) ServerExited path. The background launch path's own readiness-wait failure branch (finalize_managed_launch_readiness) and the restart path's (record_restart_readiness) both still treated a dead-and-not-Serving child as a crash unconditionally, so a `rocm services stop`/`rocm dash` stop landing during either's 45s readiness wait still got stamped `failed` on both files and a non-zero exit, exactly like a crash. Both now check the stop marker first and return cleanly (exit 0, no `failed` stamp) when it is set, mirroring the attach path. finalize_managed_launch_readiness now also refreshes its record from disk in that case and reports to its caller (via a new `bool` return) whether to skip its own follow-up bookkeeping, so that caller's subsequent write can no longer clobber a stop marker written since the record was captured at spawn time. Fixed the same class of bug at its root in the crash branch too: it was writing `record.write()` (the whole, stale, spawn-time snapshot) instead of merging just the `failed` field, which could just as easily have erased a marker concurrently written by the stop itself. B2 — the Unix branch of spawn_managed_engine_child (an engine that dies before the readiness wait even starts) never touched the manifest or the engine state file on its bail-out, leaving both at their just-created `starting`/pid-0 defaults forever — this is ROCMAI-541's original, literal symptom, on the one code path round 1 and round 2 had not actually reached despite both rounds' commit messages implying otherwise (they fixed the restart path's equivalent branch). Now marks both `failed` via merge, same as every other failure branch in this file. N1 — corrected two comments that mischaracterized rocmd's restart-recovery watcher as killing the engine it supervises directly; per apps/rocmd/src/watchers.rs, it only ever fires once the service is already dead/unreachable on its own and reacts by clearing stop_requested_unix_ms and re-exec'ing a fresh supervisor, never signalling the old engine. N2 — guarded run_attached_service's ServerExited `failed` manifest merge on the manifest still naming the exact engine pid (+ start-time token) this session spawned and watched, so a reaped child's stale write can no longer clobber a manifest a successful restart has since legitimately replaced. N3 — added a unit test for ManagedChildLiveness's Unix arm: spawns a real short-lived child, polls until is_running() reads false, and asserts a second call still reads false (pins the no-double-reap contract the type exists for). N4 — dropped finalize_managed_launch_readiness's vestigial child_pid parameter; its only caller already ignored it in favor of the liveness closure. N5 — serve-25 now captures its service_id from the CLI's own printed output at launch time instead of re-deriving "the newest record" in its Then step; serve-26's attach-wait loop now calls assert_scripted_hook_was_reached on timeout, so a binary built without the e2e-test-hooks feature fails with a clear diagnostic instead of a generic 30s timeout panic. Left as-is per this round's explicit scope: the Windows ManagedChildLiveness PID-reuse race (a known fork against unmerged PR #437) and the serve-25/26 scenario id collision with that PR's own serve-25 — both cross-PR sequencing calls for a human, not this round. Verified on the MI300X box: fmt --check, build and clippy -D warnings (both with and without --features rocm/e2e-test-hooks), full workspace test suite, and cargo xtask e2e -n 'serve-2[2-6]' (serve-22/25/26 pass; serve-23/24 fail on pre-existing environment gaps unrelated to this change — missing libdrm_amdgpu.so.1 / a vLLM import error in this sandbox). Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
…, not engine identity (ROCMAI-541) Round-4 review fix on top of 7ad214c: Blocking — round 3's N2 guard compared the manifest's engine_pid/ engine_start_ticks against run_attached_service's watched child_pid before stamping failed on a confirmed crash. refresh_from_engine_state overwrites engine_pid with whatever the engine itself reports as its server_pid, which for an engine like Lemonade that spawns its own llama-server child is *that* child's pid, not the __engine-serve-http process this loop actually watches — so for the single most important case this whole ticket is about (a Lemonade-backed server that was running and then died), the comparison was false and the manifest never got stamped failed, silently undoing the earlier fix for that case. It also didn't stop the stale-write problem it was built for: the engine-state-file write right below it was unconditionally unguarded, and refresh_from_engine_state would propagate that status straight back into a newer manifest anyway. serve-25 didn't catch this because the scripted test engine never writes a server_pid, so engine_pid == child_pid happened to hold there by coincidence, not by exercising the real-engine case. Fixed by guarding on supervisor_pid/supervisor_start_ticks instead — recorded for this exact child at spawn and never touched by refresh_from_engine_state (confirmed by reading it end to end: it only ever adopts server_pid/pid into engine_pid/engine_start_ticks), but rewritten by every legitimate restart path via record_supervisor_identity. Applied the same guard to BOTH the manifest write and the engine-state-file write, where previously only the manifest had one. N1 — spawn_managed_engine_child's and start_managed_service's "normal" (non-dead-child) record.write() calls are now merges of only the fields each site actually owns (status/supervisor+engine identity at spawn; status/inference_verified_at_unix_ms at readiness) instead of a whole-file write of the in-memory spawn-time snapshot, closing the gap round 3's commit message overstated as "fixed at its root": that round only fixed the dead-child branches, not these. N2 — corrected a doc comment citing the old serve-scripted-death/ serve-scripted-stop scenario ids; the real ones are serve-scripted-death-after-startup and serve-scripted-external-stop-while-attached. N3 — serve-26's two remaining steps (another_shell_stops_scripted_service, assert_scripted_stop_not_marked_failed) now read off the service_id captured the moment user_attaches_scripted_serve_in_background first observes the record running, instead of re-scanning the services dir for "the newest record" afterward. Removed the now-unused newest-record scan helper. N4 — checked: server-recover defaults to disabled per scenario (fresh isolated config.json each run, and WatcherUserConfig::enabled is only true when a scenario explicitly opts in via `rocm automations enable`), so serve-25's manifest-failed assertion isn't exposed to that race in this harness. No change needed. Verified on the MI300X box: fmt --check, build and clippy -D warnings (both with and without --features rocm/e2e-test-hooks), full workspace test suite (one unrelated pre-existing flake in cli_progress::tests::animated_spinner_keeps_ticking_without_progress_calls under parallel load, passes in isolation), and cargo xtask e2e -n 'serve-2[2-6]' (serve-22/25/26 pass; serve-23/24 fail on the same pre-existing environment gap as before — missing libdrm_amdgpu.so.1 — unrelated to this change). Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
The branch predates the main.rs modularization, so its changes had to be relocated rather than merged in place: - `restart_internal_managed_service_with`, `record_restarted_child` and `record_restart_readiness` (plus their tests) moved to `services_text.rs`; the Windows log attach, the `ManagedChildLiveness` threading and the restart-path liveness gate are reapplied there. - `run_builtin_engine_serve_http` moved to `engine_ipc.rs`; the failure capture (`record_engine_serve_http_failure`) and the scripted-engine E2E hook (`run_scripted_e2e_engine_serve_http`) are reapplied there, and the new `aim` arm added since the branch is covered too. - `serve_cmd.rs`'s no-GPU refusal was rewritten by #703, so the `scripted_engine_death` bypass is expressed against `reuse_existing` / `host_has_no_usable_gpu` / `no_usable_gpu_refusal` instead of the old inline bail. - the branch's serve-25/26 scenario numbers now collide with scenarios that landed meanwhile, so they are renumbered serve-33/34; their `@id:` tags were already unique and are unchanged. Not yet verified — the workspace does not build on Darwin, so this merge still needs a build/clippy/test run on the Linux dev box. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
…ayout Fallout from moving the fix off main.rs, caught by the first build on the Linux box: - `read_managed_service_manifest` now lives in `services_text.rs` and was private, so `service_launch.rs`'s five `crate::`-rooted calls could not resolve. Re-pointed at `crate::services_text::` and made it `pub(crate)`. - dropped a duplicate `crate::apply_app_path_env` import left over from the pre-refactor path; `engine_runtime_select::apply_app_path_env` is the one that exists. - gated `engine_ipc.rs`'s `Duration` import on `e2e-test-hooks`, since only the scripted-engine hook uses it. Plus the rustfmt reflow the longer module paths force. Verified on the MI300X box: build (with and without rocm/e2e-test-hooks), clippy --workspace --all-targets and clippy -p e2e-cucumber --test e2e both -D warnings, fmt --check, and the full workspace suite (0 failures). The nine ROCMAI-541 unit tests run and pass. serve-33/34 pass under HIP_VISIBLE_DEVICES="" (which puts the harness on its no-GPU mock premise), and serve-33 fails when the ServerExited crash branch is reverted to the pre-fix `Ok(())`, so it discriminates rather than passing vacuously. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
Review findings on the merged ROCMAI-541 fix. The Windows spawn reused `spawn_hidden_console_with_log`, which swaps `DETACHED_PROCESS | CREATE_NO_WINDOW` for `CREATE_NEW_CONSOLE`. That buys nothing here and costs a `conhost` per engine: a managed engine is a background service meant to outlive the CLI, and the console was incidental to the only thing wanted from that helper, the log attach. `STARTF_USESTDHANDLES` is set independently of the creation flags, so the log can be attached while staying detached. Adds `spawn_detached_with_log`: `spawn_detached_no_inherit`'s flags plus the log. The handle duplication both variants need moves into a shared `spawn_windows_with_log`, so `spawn_hidden_console_with_log` keeps its exact previous behaviour — it still has a caller, the lemond daemon in `engines/lemonade/src/process.rs`, which does want a console. Blanket `bInheritHandles` is unchanged and still inherent to passing std handles this way; the new function's doc comment says so, and scoping it would need `STARTUPINFOEXW` with an explicit handle list. Also: document serve-33/34 in docs/testing.md, which AGENTS.md requires for an observable-behaviour change, including how to run them on a GPU host; and correct a comment still placing `run_scripted_e2e_engine_serve_http` in main.rs rather than engine_ipc.rs. The Windows paths here are `cfg(windows)` and cannot be compiled on the Linux box (the MSVC target's C dependencies need `lib.exe`), so they are checked by CI's windows-build-and-test job, not locally. Linux re-verified: fmt, clippy -D warnings, full suite, and serve-33/34 still pass. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
juhovainio
left a comment
There was a problem hiding this comment.
Thanks for tackling this. I read the full diff against ROCMAI-541 and have four things I'd want addressed before merge; details are inline or below.
Blocking
- CI hasn't run on this head commit (only the docs check shows up) and the PR conflicts with main. The Windows spawn/liveness paths, the changed
spawn_hidden_console_with_logpath used by the lemond daemon, and the removal of#[cfg(not(windows))]on theread_optional_tail_linesimport are all unverified. Needs a rebase and green required checks. - serve-33 pins the wrapper text ("exited unexpectedly") and the non-zero exit, but not the engine's own failure reason. That string is emitted even when the log tail is empty, so the scenario would stay green if the reason never reached the log or the CLI output, which is the core ask of the ticket. Please assert the scripted death message in the output and the service log too.
- The Windows log attach (the headline fix) has no test that runs on Windows; the new liveness test is Unix-only and serve-33 would not catch child output still going nowhere. A pure helper or injectable seam that the Windows job can exercise would do it.
- Docs aren't synced:
rocm serve --verbosenow exits non-zero with a new message where it used to print "server process exited" and exit 0. README, the--helptext and docs/manual-testing.md still describe the old behavior.
Non-blocking / questions
- On the non-verbose path a crash is only reported after the full 45s readiness wait, and that path only has a unit test that doesn't assert the exit code.
engine_ipc.rs: the wrapper stampsfailedon anyErrfrom the engine entry. If an engine returnsErrwhen stopped viarocm services stop, an intentional stop would readfailed; I couldn't tell from the diff whether the stop marker is consulted on this path.managed_service_stop_was_requestedtreatsstatus == "stopped"as a requested stop. Please confirm nothing other than an operator stop (liveness refresh, rocmd reconciliation) can writestoppedbefore that check, or a crash would be reported as a clean exit.- The error chain can end up in the service log more than once (explicit append, tracing, and the CLI's own
Error:print now that stdio is attached). - The "mark failed" merge pair is repeated in five places and the stop-vs-crash decision in three; a helper each would help.
- Mutation evidence in the PR text covers serve-33 only; serve-34, the restart-path tests and the stop-marker tests should be shown to fail with their target broken too.
- At about 2100 lines, with stop-marker, restart-path, zombie-reaping and merge-vs-write conversions beyond the ticket, this would be easier to review split up.
- docs/testing.md cites
engine_ipc.rsandserve_cmd.rsas bare backtick paths; AGENTS asks for relative links.
| assert_scripted_hook_was_reached(&output); | ||
| assert_ne!(rc, 0, "expected a non-zero exit, got 0:\n{output}"); | ||
| assert!( | ||
| output.contains("exited unexpectedly"), |
There was a problem hiding this comment.
This asserts only "exited unexpectedly", which managed_engine_exit_failure_detail emits even when the log tail is empty. Please also assert that the scripted death message ("scripted e2e engine death after startup") appears in the output and the service log; otherwise the ticket's core ask (reason reaches log and CLI) isn't covered and the Windows attach could regress unnoticed.
| /// ROCMAI-541 acceptance criterion 2: a managed engine that died before | ||
| /// ever answering HTTP must fail `rocm serve` with both a non-zero exit | ||
| /// code and a message naming it, not just the unreadable "starting" | ||
| /// status the readiness timeout alone used to leave behind. `pid_is_running` |
There was a problem hiding this comment.
Stale doc: it says pid_is_running is injected as |_| false, but the function takes child_is_alive: impl FnMut() -> bool and the test passes || false.
| ); | ||
| if proceed_with_launch_bookkeeping { | ||
| if readiness == EndpointReadiness::Serving { | ||
| // Latch the verification the wait just performed, so the readiness |
There was a problem hiding this comment.
The liveness check only runs after the 45s readiness wait returns, so a crashed engine makes non-verbose rocm serve hang the full 45s before failing, and this path has no scenario (the unit test doesn't assert the exit code). Consider feeding liveness into the wait loop, or covering it with a scenario.
| /// [`spawn_hidden_console_with_log`] already uses; scoping it would need | ||
| /// `STARTUPINFOEXW` with an explicit handle list. | ||
| #[cfg(windows)] | ||
| pub fn spawn_detached_with_log( |
There was a problem hiding this comment.
Passing std handles makes bInheritHandles true for every managed engine (previously none were inherited). A background rocm serve whose stdout/stderr is a captured pipe may now keep that pipe open for the engine's lifetime. Please verify on Windows with piped output; scoping the inheritance would need a STARTUPINFOEX handle list.
nowycondro
left a comment
There was a problem hiding this comment.
🤖 Automated review (Claude Code) · 46b7fe2
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 (merge-base c39ee0c6 through head 46b7fe23, all 7 commits). Implements ROCMAI-541 ("Windows: managed-serve failures are never logged; the user only sees server process exited", status In Progress). The description's account of the diff is accurate and unusually self-aware — it already names the main-branch scenario-number collision and the relationship to sibling PR #437 — so there is no description/scope mismatch to report.
Blocking
-
The PR has a real merge conflict with current
main, from an independent scenario-number collision —tests/e2e-cucumber/features/model_serving.feature:538,554
This PR addsScenario: serve-33/serve-34.mainhas independently addedserve-33throughserve-39for unrelated work (a native-Windows platform gate and gated-AIM scenarios) using the same numbers. Confirmed by actually dry-running the merge (git merge --no-commit --no-ff origin/mainfrom the PR head) —CONFLICT (content): Merge conflict in tests/e2e-cucumber/features/model_serving.feature— and by GitHub's ownmergeable_state: "dirty"on the live PR.
Confidence 100 · architectural · Fix: rebase onto currentmainand renumber this PR's scenarios to the next free IDs. -
No required CI check has executed for this commit. Only a Read-the-Docs build shows up (
get_check_runsreturns 0 check runs;get_statusshows a singlesuccessstatus, the docs build).build-and-test,clippy,prek, and the requiredwindows-build-and-testlane have not run at all, so none of the Windows spawn/liveness changes, thespawn_hidden_console_with_logrefactor, or the#[cfg(not(windows))]removal onread_optional_tail_lines's import have been verified by CI at this commit.
Confidence 100 · architectural · Fix: resolve the conflict above, push, and let the required checks run green before this is re-reviewable with confidence. -
README.md and docs/manual-testing.md were not updated for the new
rocm serve --verbose/--foregroundcrash behavior, violating the project's own same-change doc-sync rule (AGENTS.md §5). The change makes a crashing managed engine exit non-zero with a new message and afailedmanifest/engine-state stamp, where it previously printedserver process exitedand exited 0. Grepping both files for either string returns nothing, and the one passage in README.md that already documents the--verbose/--foregroundattach behavior (README.md:891-893, Ctrl-D/Ctrl-C) says nothing about what happens on a crash. Onlydocs/testing.mdwas touched.
Confidence 85 · mechanical · Fix: add a sentence to README.md and docs/manual-testing.md describing the new exit code/message/failed-stamp behavior on an attached crash. -
The headline Windows fix (log attachment) has no test coverage reachable from any required CI check — neither unit nor e2e.
crates/rocm-core/src/process.rs's newspawn_detached_with_log(line 101),spawn_windows_with_log(line 138) and theManagedChildLiveness::Windowsarm (line 461) have no#[cfg(windows)]test exercising them — the file's existing Windows tests (WindowsFixture-based, further down) are all pre-existing and unrelated; one of this PR's own doc comments admits "this repo's test environment has no Windows runner to exercise it on." Separately, the new e2e scenarios (model_serving.feature:538,554) depend on theROCM_E2E_SCRIPTED_ENGINE/e2e-test-hooksmechanism and are tagged@requires-no-gpuonly (no Windows tag or inclusion list). The one required Windows job that runs e2e at all (.github/workflows/ci.yml:932-954, insidewindows-release) is explicitlyE2E_ONLY_LIFECYCLE=1withE2E_ALSO_RUN_IDSnaming three unrelated comfyui scenarios, and is "Deliberately NOT built withrocm/e2e-test-hooks" per its own comment — so even if these two scenarios were added to that list, the scripted-engine hook they depend on would not compile in. This also means the PR description's claim that "CI'swindows-build-and-testjob is the real check on" the Windows paths overstates what that job actually exercises: it will confirm the code compiles and existing tests still pass, not that the new log-attach logic works.
Confidence 85 · logic · Fix: add an injectable/pure-helper Windows unit test forspawn_detached_with_log/ManagedChildLiveness::Windows, and either get serve-33/34 running on a required Windows lane withe2e-test-hooks, or add a dedicated Windows-only scenario/unit test. -
managed_service_stop_was_requested's reliance onrecord.status == "stopped"is not sound proof of an intentional stop, and this PR is the first to build a safety contract on it.apps/rocm/src/service_launch.rs:321-325treatsstop_requested_unix_ms.is_some() || record.status == "stopped"as "this exit was not a crash." Butapps/rocm/src/managed_service_status.rs:274-277(pre-existing, unrelated to this PR) writesstatus = "stopped"the instant a tracked pid is found dead during an ordinary status read (rocm services list/status,rocm dash, a daemon poll) — its own comment says so explicitly: "dead pids alone do not distinguish a stopped service from a crashed one." Nostop_requested_unix_msis set on that path. If a concurrent status read lands during this PR's new 45s launch/restart readiness windows (or the long-lived attach loop) right as a genuine crash happens, that reconciliation can flipstatusto"stopped"first, and this PR's new guard would then read the exit as a clean stop — silently swallowing the crash this whole PR exists to surface. The new tests only cover the case wherestop_requested_unix_msis set together withstatus: "stopped", neverstatus == "stopped"alone.
Confidence 85 · logic · Fix: only truststop_requested_unix_msas proof of an intentional stop; have the liveness-reconciliation path use a distinct status (e.g."crashed"/"unreachable") for a bare dead-pid discovery instead of reusing"stopped". -
serve-33's "engine state... reads failed" assertion doesn't test the code path it's meant to cover.
tests/e2e-cucumber/tests/e2e/serving_steps.rs:2344-2355asserts bothrecord["status"] == "failed"(the manifest) andengine_state["status"] == "failed". The engine-state file, however, is already stamped"failed"unconditionally byengine_ipc.rs'srecord_engine_serve_http_failure, which runs inside the detached child itself the instant the scripted engine errors — independent ofservice_launch.rs's ownServerExitedhandling and itsstill_watching_this_supervisorguard. Breaking or deleting the CLI-side (service_launch.rs) engine-state write would leave this assertion green; only the manifest-status half actually depends on the new parent-side logic.
Confidence 70 · logic · Fix: assert something that only the parent-side guarded write can produce (e.g. a supervisor-identity-mismatch case), or move the engine-state check to a dedicated unit test ofrun_attached_service's guard.
Non-blocking
None scoring above the reporting threshold — see Decisions for the author for lower-confidence/judgment items.
Decisions for the author
ManagedChildLiveness::Windowsreopens the child by bare PID on every liveness check (crates/rocm-core/src/process.rs:461-479), across windows up to 45s (launch/restart readiness) or the whole attached session — a PID-reuse race. This isn't a new hole (Windows'sprocess_is_runninghas always had this property, and PR #499's own history already accepts the equivalent gap for the stop path), but this PR is the first to key the crash/starting decision on it in three places. This PR's own commit7ad214c2explicitly flags it as deferred: "Left as-is per this round's explicit scope: the Windows ManagedChildLiveness PID-reuse race (a known fork against unmerged PR #437)... cross-PR sequencing calls for a human, not this round." PR #437 uses a race-free held-handle design for the same problem. Tradeoff: merge now with this narrow, already-accepted-elsewhere residual gap and track reconciliation with #437 separately, vs. hold this PR until it adopts #437's approach. Since this reasoning lives only in a commit message and not in the PR description, it's worth carrying forward into the description either way so a future reader doesn't have to dig for it.- Scope/size — at ~1624 insertions across 10 files, bundling the narrow Windows-log-attach fix with a broader stop-vs-crash redesign touching three call sites (launch, restart, attach) makes this harder to review and rebase (as the conflict above shows) than necessary. Consider whether the log-attach fix and the liveness/stop-vs-crash logic could land as two PRs. (non-blocking-improvement)
- Duplicated "mark failed" merge pattern across roughly six call sites (
engine_ipc.rs,services_text.rs×2,service_launch.rs×3) with no shared helper is itself not a defect, but it's the kind of duplication that produced the asymmetry in the blocking status-check finding above — factoring it into one helper that always checks the stop marker first would make that class of bug harder to reintroduce. (non-blocking-improvement)
Positive signals
- Doc comments throughout are unusually rigorous — nearly every non-obvious decision names the exact race or prior bug it guards against (e.g. why
merge_json_state_fileis used instead ofrecord.write(), whysupervisor_pid/supervisor_start_ticksrather thanengine_pidis the right identity to gate on). - Mutation-style self-verification was actually performed and documented, not just asserted: the PR text states serve-33 was confirmed to fail when the crash branch is reverted to the pre-fix
Ok(()), matching AGENTS.md's own "shown to fail when the code it covers is broken" rule. - Commit history is clean and well-labeled (round-by-round
B1/B2/B3/N1-N5commits, each individually signed off), not a trail of "wip"/"fix" noise. - The
spawn_hidden_console_with_log→ sharedspawn_windows_with_logrefactor is a careful, verified-safe extraction: the Lemonade daemon's existing caller is confirmed unchanged in behavior.
Deployment notes
rocm serve --verbose/--foreground changes observable behavior on a managed-engine crash: it now exits non-zero with a new message and stamps the manifest/engine-state failed, where it previously exited 0 and printed server process exited. Any external automation or script keyed on the old string or the old exit code will break — this should be called out explicitly once the doc-sync finding above is addressed.
What this covered
Read completely: apps/rocm/src/engine_ipc.rs, apps/rocm/src/serve_cmd.rs (diff + surrounding context), apps/rocm/src/service_launch.rs, apps/rocm/src/services_text.rs, crates/rocm-core/src/lib.rs, crates/rocm-core/src/process.rs, docs/testing.md, tests/e2e-cucumber/features/model_serving.feature, tests/e2e-cucumber/tests/e2e.rs, tests/e2e-cucumber/tests/e2e/serving_steps.rs — via four independent background review passes (engine_ipc/services_text/serve_cmd; e2e tests/docs; AGENTS.md agent-instruction adherence; git history/blame) plus my own direct, complete read of service_launch.rs/process.rs/lib.rs, README.md/docs/manual-testing.md, and .github/workflows/ci.yml's Windows e2e configuration. Also ran a local dry-run merge against origin/main to confirm the conflict, and checked the live PR's CI/check status directly via the GitHub API. The root AGENTS.md was loaded and applied as the agent-instruction file.
Fan-out ran at reduced parallelism for one group: the shared environment's subagent-concurrency ceiling was saturated by other sessions for part of this review, so the "core launch/liveness" file group (service_launch.rs/process.rs/lib.rs) was reviewed directly by this driving context rather than by an independent worker — all other passes ran as independent background workers as intended.
Disclosure on the mandatory pre-review claim-check: resolving the live PR state required fetching existing reviews and the PR body, which necessarily exposed this context to the full text of an existing human review (by a different reviewer, state CHANGES_REQUESTED, same commit) and the PR's own description before all findings above were settled. No finding above was derived from that review's content — each was independently re-derived and verified from the code, CI configuration, and git history as cited inline. Several findings overlap with that existing review's points (CI/merge-conflict status, the Windows test gap, doc sync, the stop-vs-crash status ambiguity, double-logging, the repeated merge pattern, PR size) — that is convergent independent discovery, not copying. The PR description was read in full as part of this same required call; per method it was used only at the end, to state the ticket relationship and check for a scope mismatch, not to shape findings. Not reconciled against that review's thread or any other discussion.
Did not run: cargo build/test/clippy myself (reasoned from code, the PR's own stated verification, and CI configuration rather than executing the suite in this sandbox).
🤖 Generated with Claude Code
main moved 9 commits since the first merge, and the resulting conflict meant GitHub could not build refs/pull/854/merge — so no pull_request workflow ran at all, which is why only the docs check reported. Merging unblocks CI. ROCMAI-823 took serve-33/34 meanwhile, so these are renumbered serve-40/41 once more; their @id: tags are unchanged and remain the stable reference. Review fixes: - serve-40 asserted only the wrapper text ("exited unexpectedly") and a non-zero exit. `managed_engine_exit_failure_detail` emits that wrapper even when the log tail is EMPTY, so the scenario would have stayed green in exactly the failure this ticket is about. It now pins the engine's own death message in both the service log and the CLI output. - the Windows log attach — the headline fix — had no test on the platform the bug was reported on: the liveness test is Unix-only and the scripted-engine scenarios run the Unix branch. Adds a cfg(windows) test spawning a child through `spawn_detached_with_log` and asserting BOTH streams reach the log, stderr included, since that is where an engine's error chain goes. - README's --verbose section said nothing about what a crash does; it now states the non-zero exit and the `failed` stamp, and that a deliberate stop still exits 0. - extracted `mark_service_failed` for the manifest+engine-state pair that was repeated at three sites, with the merge-not-write reasoning stated once. - docs/testing.md: relative links instead of bare backtick paths. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
|
Thanks — this was a genuinely useful read, and point 2 in particular found a real hole. Pushed as Fixed2. serve-40 didn't pin the failure reason. You were right, and for the reason you gave: I checked that the new assertion actually discriminates rather than just being longer. Breaking only the error-message tail was not enough to fail it — on Linux 3. No Windows-runnable test for the log attach. Added one in 4. Documented the behavior change. README's Non-blocking, also done: extracted Mutation evidence, as asked. Each test fails when its own target is broken:
Two corrections1a. CI wasn't a separate problem from the conflict — it was caused by it. With the PR unmergeable GitHub can't build 1b. The pub(crate) fn managed_engine_exit_failure_detail(service_id: &str, log_path: &Path) -> String {
let recent_lines = read_optional_tail_lines(log_path, 80, "service log");Restoring the gate would fail the Windows build on an unresolved import. The gate was correct before only because the sole Windows-side caller didn't exist yet. (The still-gated 4a. The docs weren't stale. Open questions, answered
On splittingFair, and I'd push back only partly. The stop-vs-crash and zombie-reaping work isn't scope creep: the first version of this fix turned every ordinary piped |
…ture
The new `a_detached_child_writes_both_streams_into_the_log` failed on the
Windows lane, in the fixture rather than the code under test. Its temp dir
embedded `std::thread::current().id()`, whose Debug form is `ThreadId(2)`,
and `quote_windows_arg` only quotes an argument containing whitespace or a
quote — so the path reached cmd bare and `(` ended the token early:
'C:\...\rocm-core-detached-log-664-ThreadId' is not recognized as an
internal or external command
Dropped the thread id; the pid alone is unique, since only this test uses
the name.
The failure was still informative: cmd's complaint reached the log file via
stderr, which is exactly the attachment the test exists to prove. What it
caught was a malformed fixture command, not a broken attach.
Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
nowycondro
left a comment
There was a problem hiding this comment.
🤖 Automated review (Claude Code) · a849ce1
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 (base 87a7c7a7 through head a849ce1, 9 commits; the 11-file, 1745/89-line diff matches the PR's own additions/deletions). Implements ROCMAI-541 ("Windows: managed-serve failures are never logged; the user only sees server process exited", status In Progress). All three of the ticket's acceptance criteria (error chain reaches the service log on Windows, rocm serve reports failure with a non-zero exit, the engine state file is stamped failed on any error exit) are addressed by this change; the PR does not claim to close the ticket, and the extra stop-vs-crash/restart-path work is additive to the literal AC text.
Blocking
-
The PR currently conflicts with
main, from a fresh scenario-number collision.tests/e2e-cucumber/features/model_serving.feature: this PR'sScenario: serve-40/serve-41collide with an independently-addedserve-40frommain's current tip (commitdb7e5408, "ROCMAI-827: start the background helper only while an automation needs it", #838), which uses the same number for unrelated work. Confirmed by directly dry-runninggit merge --no-commit --no-ff origin/mainfrom the PR head —CONFLICT (content): Merge conflict in tests/e2e-cucumber/features/model_serving.feature— and by the live PR's ownmergeable_state: "dirty".
Confidence 100 · architectural · Fix: rebase onto currentmainand renumber this PR's two scenarios to the next free IDs. -
The "child died before its readiness wait even started" branches on both the launch and restart paths stamp
failedwithout first checking whether a stop was requested, contradicting this file's own documented contract.apps/rocm/src/service_launch.rs(spawn_managed_engine_child's immediate post-spawn liveness check) andapps/rocm/src/services_text.rs(restart_internal_managed_service_with's equivalent branch, ~line 1085) both callmark_service_failed/an unconditioned engine-state merge the instant a freshly-spawned child is found dead within its first ~200ms — neither consultsmanaged_service_stop_was_requestedfirst.mark_service_failed's own doc comment inservice_launch.rsstates the precondition explicitly: "Callers reach this only aftermanaged_service_stop_was_requestedhas already ruled out an intentional/external stop." Every sibling failure branch this PR adds (finalize_managed_launch_readiness,record_restart_readiness, the attachedServerExitedarm) does consult it first; these two do not, so an externalrocm services stoplanding in that narrow post-spawn window can be reported as a crash. This is corroborated directly from this PR's own git history: commit7ad214c2's message describes adding the stop-check explicitly tofinalize_managed_launch_readiness/record_restart_readiness(its "B1") as a separate, later fix from the one that touched this exact branch ("B2" — which only fixed the manifest/engine-state write mechanism, not the missing stop-check), so the gap was never actually closed for this branch in either round. On the restart side specifically, the bug compounds:update_restarting_record's CAS guard (names_same_processes+ status match) would detect a concurrent stop (the status would already differ) and returnOk(None), but thatOptionreturn value is discarded — so the engine-state file is still stampedfailedunconditionally and the restart command stillbail!s with a failure message regardless of what the CAS detected. No test exercises either branch against a concurrent stop (unlike the readiness-wait branches, which have dedicated tests for exactly that race).
Confidence 85 · logic · Fix: have both branches callmanaged_service_stop_was_requestedbefore marking failed, the same way their sibling branches do; have the restart branch checkupdate_restarting_record's return value before writing the engine-state file or bailing. -
ManagedChildLiveness::Windows— the liveness arm that is the actual crash-detection primitive on Windows — has no test reachable by any required CI check. Its own test module says so directly: the only test exercisingManagedChildLiveness(managed_child_liveness_unix_reports_dead_after_exit_and_does_not_double_reap) is#[cfg(not(windows))], and its doc comment states the Windows arm "is not exercised here." Separately verified against.github/workflows/ci.yml: the one required Windows e2e lane (windows-release's "Acceptance install lifecycle" step) runs withE2E_ONLY_LIFECYCLE=1and a fixedE2E_ALSO_RUN_IDSallowlist of unrelated scenarios, and its own comment states it is "Deliberately NOT built withrocm/e2e-test-hooks" — so the new scripted-engine scenarios this PR adds could not run there even if added to that list; their compiled-in test seam does not exist in that binary. By contrast, the sibling Windows fix in the same file —spawn_detached_with_log, the log-attachment half of this same ticket — does have a dedicated#[cfg(windows)] #[test](a_detached_child_writes_both_streams_into_the_log), which runs and passed in the "Windows build and test (debug)" CI job on this commit. So a Windows CI runner capable of exercisingcfg(windows)unit tests is demonstrably available and used for one half of the fix, but not the other. This is exactly the case AGENTS.md §3 names: "Platform-gated code gets a test that runs on its gate... If a path stays untested, say which and why in the PR text" — the PR body's "CI'swindows-build-and-testjob is the real check on" the Windows paths overstates what that job verifies for this specific arm (compiles and doesn't regress existing tests, not that the new liveness logic is correct).
Confidence 85 · architectural · Fix: add a#[cfg(windows)] #[test]forManagedChildLiveness::Windows(a real detached child, killed and polled for liveness, analogous to the existing Unix test), or at minimum say in the PR text that this specific arm stays unverified and why. -
docs/manual-testing.mdwas not updated for the newrocm serve --verbose/--foregroundcrash behavior, though its own Section 3 walks through exactly that scenario. The change makes an attached managed engine that dies on its own exit non-zero with a crash message and afailedmanifest/engine-state stamp, where it previously exited 0 and printed "server process exited." README.md was correctly updated (new paragraph after the existing Ctrl-D/Ctrl-C bullets) anddocs/testing.mddocuments the new e2e coverage, butdocs/manual-testing.md's "3. Lemonade GPU Verification" section — which already manually walks a tester through this exact--foregroundattached session and its Ctrl+D/Ctrl+C behavior — says nothing about what happens if the engine dies on its own, so a manual QA pass following this checklist would not catch a regression here. AGENTS.md §5 is explicit: "when a command's ... observable behavior changes, update README.md, its --help/doc comment, docs/testing.md, and docs/manual-testing.md in the same change — do not leave user-facing docs for a follow-up." (The--helptext for--verbose/--foregroundmakes no claim about exit behavior either way, so no change is needed there.)
Confidence 85 · mechanical · Fix: add a step to Section 3 (e.g., kill the Lemonade child while attached) naming the expected non-zero exit, crash message, andfailedstatus.
Non-blocking
- The new scripted-death e2e fixture uses a fixed 1.5s sleep with no explicit synchronization against the ~200ms early-startup liveness-check window it is meant to clear.
tests/e2e-cucumber/tests/e2e/serving_steps.rs's scripted-engine death timer is a bare wall-clock delay, not gated on any observable signal that the liveness check actually completed; on a slow/loaded CI runner this margin (currently ~7.5x) could in principle narrow enough to land the death inside the startup window instead of after it, producing an intermittent failure in a different code path than intended. No actual flakiness was observed or reproduced; this is a structural risk rather than a confirmed one.
Confidence 70 · logic · Fix: have the scripted engine wait on an explicit signal (a file/env marker the real liveness check writes) rather than a timer, or make the margin generous and document why it's believed sufficient.
Decisions for the author
ManagedChildLiveness::Windowsre-derives liveness from a bare PID on every check (a PID-reuse race) across windows up to 45s or a whole attached session — tradeoff. This is not a new hole (process_is_runninghas always had this property), and this PR's own commit7ad214c2documents it as a deliberate, already-known deferral: "Left as-is per this round's explicit scope: the WindowsManagedChildLivenessPID-reuse race (a known fork against unmerged PR #437)... cross-PR sequencing calls for a human, not this round." That reasoning currently lives only in a commit message; carrying it into the PR description would save a future reader from digging for it.- The "mark failed" merge pattern is duplicated across roughly six call sites with no shared helper — non-blocking-improvement. Not a defect by itself, but it's the kind of duplication that let the blocking stop-check asymmetry above exist; one helper that always checks the stop marker first would make that class of bug harder to reintroduce.
- PR scope/size — non-blocking-improvement. At ~1745 insertions bundling the narrow Windows-log-attach fix with a broader stop-vs-crash redesign across three call sites, this is harder to review and rebase (as the live conflict above shows) than the two pieces would be split.
Positive signals
- Doc comments throughout are unusually rigorous about why, not just what — e.g.
mark_service_failed's comment states its own precondition and the reason merges are used instead of whole-record writes (to avoid erasing a concurrently-written stop marker), which is exactly the contract the blocking finding above traces a violation of. record_engine_serve_http_failure's tracing call and the explicit service-log append do not actually double-log:apps/rocm/src/logging.rswires the process'stracingsubscriber to a dedicated, file-only, non-blocking appender (~/.rocm/logs/rocm-cli.log.*) that explicitly never touches stdout/stderr, so the two writes land in two different files rather than duplicating in the onerocm logstails.- Mutation-style self-verification was actually performed and documented: the PR text states the crash scenario was confirmed to fail when the fix is reverted to the pre-fix
Ok(()). - Commit history is clean, round-labeled (B1/B2/B3/N1-N5), and individually signed off rather than a "wip"/"fix" trail — and one of those commit messages is itself the clearest evidence for the blocking stop-check gap above.
Deployment notes
rocm serve --verbose/--foreground changes observable behavior on a managed-engine crash: it now exits non-zero with a new message and stamps the manifest/engine-state failed, where it previously exited 0 and printed "server process exited." Any external automation keyed on the old string or exit code will break — worth calling out explicitly once the doc-sync finding above is addressed.
What this covered
Read completely: apps/rocm/src/engine_ipc.rs, apps/rocm/src/serve_cmd.rs, apps/rocm/src/service_launch.rs, apps/rocm/src/services_text.rs, crates/rocm-core/src/lib.rs, crates/rocm-core/src/process.rs, docs/testing.md, docs/manual-testing.md, README.md, tests/e2e-cucumber/features/model_serving.feature, tests/e2e-cucumber/tests/e2e.rs, tests/e2e-cucumber/tests/e2e/serving_steps.rs — via three independent background review passes grouped by coupling (engine_ipc/serve_cmd/lib.rs; service_launch/process/services_text; e2e-tests/docs/README), each given the full files plus an exact diff patch, followed by my own direct agent-instruction-adherence pass against the root AGENTS.md and a git-history/blame pass (git log, reading the PR's own round-by-round commit messages) over the same files. Verified via direct code tracing rather than assumption: the merge conflict (live git merge --no-commit --no-ff dry-run against current origin/main), the CAS/discard bug in update_restarting_record's caller, the Windows CI job's actual e2e configuration (.github/workflows/ci.yml), and the tracing-subscriber's file-only destination (apps/rocm/src/logging.rs, read for this purpose though outside the PR's diff). Did not run cargo build/test/clippy myself; relied on the GitHub Checks API, which shows all 25 check runs for this commit completed with conclusion success (one skipped, the non-required skill-evals job) — no failing required check to report.
Did not run as separate passes: "prior changes" (no prior-PR review comments were read, by design — only commit_id/login/state metadata was used for the mandatory claim-check); "code comments" was folded into the coupled-group passes above rather than run as a standalone pass.
Disclosure: the mandatory pre-review claim-check (get_reviews) and the PR-metadata fetch needed to read mergeable_state both returned full review/PR bodies as a side effect, which is more than the task intended me to see before findings were settled — this included an existing human reviewer's CHANGES_REQUESTED review and a prior automated review at an older commit. No finding above was taken from either; each was independently re-derived and directly verified against the code, git history, and CI configuration as cited inline (the live merge-conflict re-check in particular shows the conflict is a new, different collision than any earlier round would have seen, since main has moved again). Several findings likely overlap with points raised in those earlier texts — that reflects the same defects being independently reachable from the code, not reliance on having read them. Not reconciled against either review's thread.
🤖 Generated with Claude Code
|
Addressing the automated pass ( The one that mattered — and where I was wrong@juhovainio asked whether anything other than an operator stop can write
What I changed. Added What I could not close, and why. The latch narrows the race; it does not close it. I removed the A stop can be requested, signal, and confirm — clearing its own marker — well inside one 200 ms poll, and a confirmed stop leaves no other trace. So the status term stays. The residual race is now documented at the guard with that evidence and the two ways to close it properly (a distinct status for a bare dead-pid reconciliation, or a durable marker for a confirmed stop) — both of which change bookkeeping shared with I'd rather ship this as a documented, narrowed, pre-existing race than either leave it as an unstated assumption or expand this PR into shared status semantics. Already fixed before this pass was writtenThe review ran against
Also addressed now
On splittingKeeping it as one PR. The stop-vs-crash work isn't adjacent scope — it exists because the headline fix would otherwise turn every ordinary piped Verificationfmt, |
Symptom
On Windows, a managed engine that failed left no trace of why. The service log
stopped at its last progress line,
rocm serve --verboseprinted onlyserver process exitedand exited 0, the service manifest said stopped, andthe engine state file stayed at
starting.Root cause
Two independent gaps.
The output went nowhere.
spawn_managed_engine_childspawns the internal__engine-serve-httpchild per platform. The Unix branch callsattach_background_stdio, so the child's stdout/stderr land in the service log.The Windows branch called
rocm_core::spawn_detached_no_inherit, which attachesthem to nothing — so the error chain a failing engine prints to stderr reached
no one.
Nobody checked whether the child was still alive. Neither platform looked at
the child's fate after spawning it, so a readiness wait that times out was
indistinguishable from one that will never succeed because the child already
died, and the attached log-streaming loop treated every self-exit as a graceful
stop.
Fix
Servingreadiness result, on thelaunch path (
finalize_managed_launch_readiness), the restart path(
record_restart_readiness) and the attached path. A dead child fails thecommand and stamps
failedon both the manifest and the engine state file; areadiness timeout on a child that is still alive keeps reporting its previous
status and exit 0, since a large model can legitimately take longer than the
budget.
AttachOutcome::Stopfires onlyfrom the attached session's own keystroke reader, so a
rocm services stopfrom another shell lands in the same
ServerExitedarm as a crash — and pipedCI stdio has no keystroke reader at all. The stop marker
(
stop_requested_unix_ms) is now consulted first, so an intentional stop staysexit 0 with no
failedstamp.ManagedChildLiveness, which retains theChildand callstry_wait(). The managed engine is a direct child(
setsidonly, no double fork), sokill(pid, 0)succeeds for a zombie thisprocess has not reaped — a signal probe alone reproduced the original symptom
on Linux.
run_builtin_engine_serve_httplogs the fullerror chain and marks the engine state file
failedon any error exit,covering a gap in the attach and any engine path that errors without updating
its own state file.
Notes for review
This revives work from 2026-10-15 that was never pushed. It was written
against
af315e0a, 113 commits behind, andmainhas since moved most of thetouched code out of
main.rs. The merge commit relocates it:restart_internal_managed_service_with/record_restarted_child/record_restart_readinessintoservices_text.rs, andrun_builtin_engine_serve_httpintoengine_ipc.rs(whoseaimarm, addedmeanwhile, gets the same failure capture). The branch's
serve-25/serve-26are renumbered
serve-33/serve-34—maintook those numbers meanwhile; the@id:tags were already unique and are unchanged.Relationship to #437 (ROCMAI-675). The two are complementary, not competing.
#437 makes Windows notice that the child died and read its exit code; this
makes the reason survive. They are orthogonal arguments to the same private
primitive:
spawn_windows_no_inheritalready takes an optional stdio-handlepair, and #437 adds an optional settle duration. #437 should rebase onto this,
after which its Windows spawn can set both.
New
spawn_detached_with_log. The first version of this fix reusedspawn_hidden_console_with_log, which swapsDETACHED_PROCESS | CREATE_NO_WINDOWfor
CREATE_NEW_CONSOLE— aconhostper engine, for a background service thatwants no console.
STARTF_USESTDHANDLESis independent of the creation flags, sothe log is attached while staying detached.
spawn_hidden_console_with_logkeepsits exact previous behaviour for its own caller, the lemond daemon.
Blanket
bInheritHandlesis unchanged and inherent to passing std handles thisway; scoping it would need
STARTUPINFOEXWwith an explicit handle list.Known limitations carried forward
Residual stop-vs-crash race (documented, not closed). The guard that keeps a
deliberate stop from being reported as a crash still consults
status == "stopped", which is not sound proof:refresh_managed_service_runtime_livenessreconciles any record with all-dead pids to
"stopped"without a marker, so aconcurrent
rocm services/rocm dash/daemon read can relabel a crash.StopWatchnarrows this (it latches any stop seen while the child was still watched) but cannot
close it — removing the status term turns the external-stop scenario red, because a
confirmed stop clears its own marker inside one poll interval and leaves no other
trace. Closing it needs a distinct status for a bare dead-pid reconciliation, or a
durable marker for a confirmed stop; both change bookkeeping shared with
rocmdandthe dashboard, so they are deliberately out of scope. Reasoning and evidence are at
managed_service_stop_was_requested.Windows PID-reuse on
ManagedChildLiveness. The Windows arm reopens the child bybare PID on each liveness check, across windows up to 45 s (or a whole attached
session). This is not a new hole — Windows's
process_is_runninghas always had it,and the stop path already accepts the equivalent gap — but this change is the first to
key the crash/starting decision on it. PR #437 uses a race-free held-handle design for
the same problem, so this is left to the #437 reconciliation rather than forked here.
Deployment note
rocm serve --verbose/--foregroundchanges observable behavior on a managed-enginecrash: it now exits non-zero with a new message and stamps the manifest and engine
state
failed, where it previously printedserver process exitedand exited 0. Anyexternal automation keyed on that string or on the old exit code will need updating. A
deliberate stop is unchanged and still exits 0.
Verification
Run on a Linux MI300X box:
cargo build --workspace(with and withoutrocm/e2e-test-hooks),cargo clippy --workspace --all-targets -- -D warnings,cargo clippy -p e2e-cucumber --test e2e -- -D warnings(the--all-targetssweep does not cover the e2e step files),
cargo fmt --all --check, and the fullworkspace suite — 0 failures.
serve-33/34 pass under
HIP_VISIBLE_DEVICES="", which puts the harness on itsno-GPU premise. serve-33 was confirmed to fail when the
ServerExitedcrashbranch is reverted to the pre-fix
Ok(()), so it discriminates rather thanpassing vacuously.
Pushed with
SKIP=cargo-clippy,cargo-test: those hooks cannot compile on macOS(
rocm-engine-lemonadeusesst_mode,u16on Darwin andu32on Linux). Bothwere run on Linux instead, as above — the skip records where they ran, not that
they were waived.
verify-commits,license-headers,cargo fmtand the e2eclippy hook all ran on push.
Not verified locally: the Windows paths. They are
cfg(windows), and theMSVC target's C dependencies need
lib.exe, which the Linux box does not have.CI's
windows-build-and-testjob is the real check on them.