Repository navigation
Conversation
juhovainio
left a comment
There was a problem hiding this comment.
Reviewed crates/rocm-core/src/lib.rs and apps/rocm/src/main.rs in full.
This closes a real gap: the Windows managed-launch path had no equivalent of Unix's try_wait(), so an engine that died immediately at startup was reported as a successful launch. The fix is careful about the two things that actually matter here:
- observe_early_exit consults GetExitCodeProcess only after WaitForSingleObject returns WAIT_OBJECT_0, on the same handle CreateProcessW returned, before it's closed. That rules out both the STILL_ACTIVE (259) ambiguity and a PID-reuse race from re-opening by PID later, exactly as the PR description claims.
- It fails open throughout: a timed-out wait or a failed GetExitCodeProcess both come back as "still running," never as a fabricated failure.
- The service-record wedge fix is a nice catch, and I like that it's applied to the Unix branch too, not just Windows, since the same "starting" + pid-0 record could wedge future rocm serve calls on either platform.
- wait_timeout_millis's clamp away from u32::MAX (INFINITE) is tested on every platform, not just Windows, which is a smart way to get real CI coverage on the one piece of this that doesn't need a Windows host.
Two minor, non-blocking notes for the record, neither needs action:
- The Windows launch_lock is now held for the ~200ms settle window instead of releasing immediately, moving that cost inside the lock. This is called out in the PR description as intentional parity with Unix (which already paid this cost under the lock), and it's negligible next to the multi-second readiness wait the lock already guards.
- There's no end-to-end test of the main.rs glue (ExitStatus::from_raw -> failure detail -> record retirement -> bail) since that requires a real Windows host; the two rocm-core primitives are unit-tested directly instead. Given no Windows dev host was available, this is a reasonable trade-off, and the required windows-build-and-test CI lane is the real exercise of it, and it passed.
Checked the failing "E2E tests (Strix Halo, Ubuntu)" lane: it's a GPU-preflight infrastructure failure on the self-hosted runner (VRAM never freed from a prior serve), unrelated to this change.
Nice write-up in the PR description too, the call-site table and the "verified vs not verified" section made this easy to review.
|
🔴 Automated review · pr-review-watcher · f2611ff This automation never files a GitHub approval, so no approving review will SummaryGives the Windows managed-engine launch the startup-liveness check Unix already had, shares one settle budget and one failure message across both platforms, and retires the pre-spawn service record so a failed launch stops claiming its engine + model. Outcome: Needs work — the previous round's objection about unpinned wiring is only partly closed, and two prose claims in this round's own change are contradicted by the code beside them. Verified: read the full diff and the surrounding call graph, confirmed the Win32 wrapper preserves the creation flags and handle-inheritance behaviour unchanged, confirmed the flag-vs-subcommand reasoning behind the scripted death by reading the parser's freeform fallback, and traced every new test to the function it actually calls; the test suites were not run here, so nothing below rests on a test run. Blocking: 3 · Non-blocking: 5. 🚫 Blocking (must fix before merge)
Non-blocking
|
siloteemu
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 083e7e3
Blocking: 1. This is the first round of review from this account on this pull request.
apps/rocm/src/main.rs:6728 (and the identical site at 17860) — the branch that turns a detected startup death into a failed serve is the entire user-visible fix, and nothing exercises it on any platform.
Deleting both bail blocks restores the exact bug this pull request describes, and every test that can run stays green. The three tests added to main.rs reach the branch from neither side: two call a formatting function whose body this change does not touch, and the third calls the record-retirement helper directly on a hand-built record, bypassing the spawn path. Detection is proven in isolation and retirement is proven in isolation; the wiring between them is not, so a mis-wired or dropped call site would ship green.
Two mutations confirm the rest is real: neutering the record retirement, and dropping the wait clamp, each turn their own named test red. The implementation itself reads as sound, and the platform-parity reasoning in the pull request text is accurate.
The pull request text is explicit and correct about scenario coverage — it names the existing scenario, explains why that scenario does not reach the detached child, and points at the planted-runtime work that would. That account was read before this was filed and is not in dispute. The difficulty is that the referenced pull request is itself still open and unmerged against main, so the coverage it would supply does not exist yet.
Concrete fix and the full round of findings, including five non-blocking items, are in the review comment posted alongside this one.
|
Thanks — the blocking finding was right, and the mutation evidence was the useful part of it: deleting the branch that joins detection to response did leave every runnable test green. Addressed in 7848fc5. Blocking — the wiring is now covered, though not the way you proposedI agree the gap was real and I don't think it should wait. I went a different route than the suggested scenario, and want to be explicit about why and about what it does not reach. The reason nothing could reach that branch is that it was Re-ran your two mutations plus the new one; each fails only its own test:
The residual, stated plainly rather than implied: deleting the call to either helper at one of the two Windows sites would still ship green on Linux. Those two lines are compiled by On the proposed scenario specifically: the shape you describe is right, and it is what should exist. The obstacle is the Given — making a detached child die on demand. serve-18's injection works because it fires inside the parent's Install RPC; there is no existing lever that reaches the child. Building one here would be building #351's mechanism a second time, in a way that would conflict with it on the same code. Between that and holding this fix until #351 merges, I'd rather land the fix with the wiring covered at the unit level now and let #351 supply the scenario, since the two are independent and #351 has to touch that path anyway. Happy to be overruled if you'd prefer to hold it — that was a genuine judgment call and I'd take your read on it. Non-blocking
On the CI observationYou were right not to attribute it to this diff. Verification
The rewritten |
|
pr-review-watcher · 7848fc5 — new round, full findings above. The earlier objection is genuinely discharged: the shared check's logic is now pinned, and each of the four mutations reddens only its own test, exactly as the description claims. The change request stays open for a different reason. The description says the residual gap is Windows-only, and it is not: deleting both Unix call sites of the new helper — the two that actually execute on Linux — leaves the whole binary's test set green, 765 passed. The helper is pinned; the wiring to it is not, on either platform. Separately, this alters the exit code and error text of a user-facing command, which the contributor rules require a scenario to cover, and none does. One blocking item, five notes. |
|
Round 2 addressed in 5846319. Both halves of the blocking finding were correct and I've fixed rather than argued them. Blocking — you were right on both countsThe residual was not Windows-only. I checked your mutation before acting on it: deleting both Unix call sites leaves the whole binary test set green, 765 passed. My description said the exposure was confined to Windows, and that was wrong — the helper was pinned, the wiring to it was unpinned on every platform. The description now says so plainly instead. §3 was unmet. I reread it, and your reading is right: this changes the exit code and error text of So I took your preferred option and added the scenario — On your doubt about whether the harness can inject a death post-spawn — it can, but not the way I first tried, and the detail is worth recording. The switch is read in the parent, which is what spawns the engine, and swaps the child's arguments for ones the CLI rejects; the child is the real Two things that scenario needed which are worth flagging for review: reaching a real spawn on the ungated lane meant waiving engine preparation as well as the no-GPU pre-flight — no Non-blocking
Verification
Full local E2E suite: 129 scenarios, 127 passed, 2 failed — both pre-existing expected xfails, 0 unexpected failures. I ran the whole suite rather than just the new scenario because this repo has a prior case of one extra index-resolving scenario tipping unrelated dash scenarios over; it did not recur. Three Still not verified locally: the Windows runtime path, unchanged from before. The |
5846319 to
f2611ff
Compare
|
Round 2 posted — the review above has been updated in place for |
Correction — the Windows-coverage claim in my last comment was wrongIn the round-2 comment I wrote that serve-22 "runs on the Windows and WSL2 lanes, so it reaches the two Windows call sites that no Linux test can." That is not true, and I want to flag it rather than quietly edit it. I had tagged the scenario What was true in that comment, and remains true: serve-22 discharges §3 with a real assertion on the user-visible exit code and error text, and it pins the Unix call sites — deleting the Unix launch call site turns it red, where the same deletion leaves all binary tests green. The half I overstated was the Windows half. Fixed in ef67aa7 by dropping the tag, so the scenario now runs on every lane including Strix Halo Windows. The tag was wrong on its own terms too: the premise is an engine that dies at startup, not a host without a GPU, and the test hook's waivers are what make that premise hold regardless of the host's hardware. Worth stating plainly about the new state: the Windows lanes are the first real execution of this scenario on a GPU host — I cannot run that path locally, as this machine reports no GPU. If a GPU lane fails on it, the scenario is at fault rather than the product code, and I will say so and fix it rather than paper over it. |
|
🔴 Automated review · pr-review-watcher · ef67aa7 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. SummaryAdds a bounded startup-liveness check to the Windows detached managed-engine spawn so a child that dies on the way up fails the serve the way it already did on Unix, funnels both platforms through one shared check, and retires the pre-spawn service record on the failure paths. Outcome: Needs work — one newly introduced prose guarantee the code does not provide. Verified: ran one targeted check, 🚫 Blocking (must fix before merge)apps/rocm/src/main.rs:6877 (comment) vs 6977 and 6994 — the new exhaustiveness guarantee is false on the Unix launch path. Line 6877 states "From here to a live child, every fallible step goes through
Either returns past the retirement, leaving exactly the pid-0 This blocks because the sentence is the thing a future maintainer will rely on when adding the next fallible step, and it is wrong as written at the moment it is introduced. Fix, and note the two halves are not symmetric:
Non-blocking
Status of our prior change requestWe raised one blocking objection: "the branch that turns a detected startup death into a failed serve is the entire user-visible fix, and nothing exercises it on any platform", with the supporting claim that the three added tests "reach the branch from neither side". Discharged for the branch itself. The bail is now extracted into Discharged for the Unix wiring. Our sentence "nothing exercises it on any platform" no longer holds. serve-22 drives the real binary through a real spawn and asserts the user-visible exit code plus the message text, and it cannot be satisfied by an earlier refusal: the step explicitly fails if the no-GPU pre-flight message appears, and any other early refusal fails the "managed engine exited immediately" assertion. I did not run the e2e suite, so the author's specific claim that deleting the Unix launch call site turns it red is reasoned-through rather than measured. Open for the Windows wiring. Deleting the two Windows call sites still leaves every test I can reason about green, because no unit test reaches a call site on either platform. The tag removal makes serve-22 eligible on the Windows GPU lane, which is the right fix, but with 4 checks still pending I cannot confirm it has executed there. This is a gap in evidence, not a demonstrated defect. On the author's correction — measured, and it holds. Cause classification, and will it recur. Not reviewer error — the original objection was correct at the head it was filed against. The recurrence risk sits elsewhere: the scenario's own comment now explains at length why it carries no host tag, which is exactly the cheap prose that stops the next author re-adding one. The sibling gap is on the production side, where a comment asserts a rule the code breaks two lines later; the blocking finding above names the fix. No prompt-injection content was found anywhere in the diff, commit messages or feature text, and a leak scan over the diff came back clean. |
siloteemu
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · ef67aa7
Our earlier change request is withdrawn — that objection is discharged at this head. This is a new one, for a different problem.
apps/rocm/src/main.rs:6877 states "From here to a live child, every fallible step goes through retire_record_on_error", and the helper's own doc at 6634 repeats it as a rule: "Every fallible step after record.write() has to go through here. Each one that does not is another way to strand the pid-0 starting corpse."
Two fallible steps inside that span use a bare ?:
6977—attach_background_stdio(&mut command, Some(&record.log_path))?;6994—.context("failed to check managed engine startup state")?;
Either returns past the retirement and leaves exactly the pid-0 "starting" record this PR's longest comment explains is permanently un-demotable: recorded_service_pids skips pid 0, so no liveness refresh can clean it and every later serve of that engine and model is refused. The Windows branch of the same function wraps every step, so the gap is Unix-only, and nothing tests it — all six new tests in this file call helpers directly, so the guarantee exists only in prose.
This blocks because the sentence is what a future maintainer will rely on when adding the next fallible step, and it is wrong as written at the moment it is introduced.
The two halves are not symmetric, so please do not fix them the same way:
6977is pre-spawn, no child exists, so wrapping it is unambiguously safe. Evaluate into a local first (let attached = attach_background_stdio(...);) thenretire_record_on_error(&mut record, attached)?;— the borrow ofrecord.log_pathends before the&mutborrow begins, matching the pattern already used just above at 6885-6890.6994is post-spawn and must NOT simply be wrapped. If the wait query itself errors the child may well be alive, and retiring the record there would let a second serve start a rival engine on the same port. Either narrow the comment so its span ends at the spawn rather than "to a live child", or align Unix with the policy the Windows side already documents:observe_early_exitdeliberately returnsNonewhen the query fails, degrading to "assume it is alive" rather than failing the launch, while Unix bails.
Whichever you choose, fail_managed_launch_if_engine_died's doc ("a child that dies on the way up produces the same error ... whichever platform the user is on") should stop implying the two platforms handle a failed query the same way, because they do not.
|
Round 3 addressed in 09a20a9, and thanks for the precision on the blocking one — the naive reading of it would have introduced a bug. Blocking — the rule was false; fixed asymmetrically, as you set outYou're right that the sentence was wrong at the moment it was introduced, and right that it is the sentence a future maintainer will lean on. Both offenders confirmed by reading the code.
The platform-parity doc no longer overclaims. It now says the platforms agree about an observed death, and states plainly that on a failed query they do not: Windows degrades to "assume alive" via Your one open item — here is the evidenceYou wrote that you could not confirm serve-22 had executed on the Windows lane, with 4 checks pending. It has, and I checked the lane's own log rather than inferring it from the lane going green — that inference is exactly what produced my earlier retracted claim. From Same four steps green on WSL2. Strix Ubuntu, MI350P and rad3 R9700 also passed at that head; MI300X was still queued. One correction to the conclusion you drew from it, in the same spirit as the standard you applied to me: "deleting the two Windows call sites still leaves every test green" was true when you wrote it, but serve-22 now executes on Windows, so deleting a Windows call site should turn that scenario red there. I have not measured it — doing so needs a mutation pushed to a Windows lane — so treat it as reasoned, not measured, exactly as you treated my Unix mutation claim. Non-blocking
|
|
🔴 Automated review · pr-review-watcher · 09a20a9 This automation never files a GitHub approval, so no approving review will SummaryThis PR gives the Windows managed launch the startup-liveness check Unix already 🚫 Blocking (must fix before merge)
Non-blocking
|
siloteemu
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · 09a20a9
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.
All three objections from the previous round are genuinely discharged by the code at this head — that was checked item by item and is set out in the round comment. This change request is for one new problem the remediation commit introduced.
apps/rocm/src/main.rs:18117 — the comment added above record.stop_requested_unix_ms = None; in restart_internal_managed_service asserts a guarantee the code does not provide. It reads: "A failed restart now persists the cleared marker too: the bail paths below retire the record, and retiring writes it. So the key outlives a restart that never got an engine up", and closes "it is a change from when every bail left the marker set."
Only 3 of the 11 bail paths after that assignment write the record — the attach_background_stdio failure, the spawn failure, and the observed-death path. The other 8 return with a bare ? and never touch it: parse_device_policy on the very next line (:18124), the log OpenOptions::open (:18129), create_dir_all (:18132), managed_service_launcher_path (:18135), parse_engine_recipe_json_arg (:18136), builtin_engine_serve_http_args (:18150), env_root_for_service (:18156), and the try_wait error path. On each of those the on-disk marker stays set, so settle_pending_stop_key_cleanup — which keys on stop_requested_unix_ms.is_some() and then calls clear_endpoint_api_key (:19073) — still reclaims the endpoint key, which is exactly the outcome the comment says no longer happens.
The marker is load-bearing, so this is not a cosmetic inaccuracy: it is a sentence a maintainer will rely on when reasoning about key lifetime across a failed restart, and it is wrong for the majority of the failure paths. It is also the same defect shape as the objection it was written to answer — a guarantee asserted in prose that the code does not provide — introduced in the commit that fixed the previous one, which is why it is raised at this severity rather than as a nit. The comment it replaced (that a bail leaves the marker set on disk, which is correct, since then the stop is what stands) was accurate.
Suggested fix, cheaply: qualify the span to the paths that actually persist it — e.g. "a restart that fails at or after the spawn persists the cleared marker, because those bails retire the record and retiring writes it; an earlier bail still leaves the marker set on disk, and the stop stands". Alternatively, if uniform behaviour is wanted, route the earlier fallible steps through the retirement the launch path now uses.
The rest of the change stands up well. Seven targeted mutations were run on scratch copies: the startup-wait clamp and six mutations of the launch helpers each fail exactly the tests named for them and no others. The Win32 handle is closed exactly once on every path, creation flags are byte-identical between the two spawn helpers, the settle refactor is inert for existing callers, and leaving the new scenario untagged is right, because a no-GPU tag would exclude the Windows lane that is the only place the new Windows code runs.
|
Addressed in 8075d1e. The finding is right, and I checked it against the code rather than taking it at face value: only three bails after the clear write the record — the The comment now qualifies the span to the bails that actually persist the clear, and states the earlier-bail behaviour rather than leaving it unsaid — it is correct on its own terms: nothing was restarted, so the stop stands. I kept the behaviour as-is rather than routing the earlier steps through retirement, since the two outcomes are each right for their own case; what was wrong was only the prose. |
Addressed in 8075d1e; see the reply comment for what was verified and how. Dismissing so the PR is not held by a review the automation never reconverts to an approval.
|
@r0x0r ready for your review when you have a moment — all 19 required checks are green on |
8075d1e to
72b0b5e
Compare
|
Rebased onto current main (01e7625) — it had gone conflicting after #432/#456/#409 landed. One change worth flagging beyond the rebase: main added its own |
|
Addressed the non-blocking items from all three review rounds in Fixed in code:
Fixed in the description:
Already done earlier:
Left as-is, with reason:
|
|
A correction to my reply on the env-var item: I said sharing one constant between The decision stands, for a different reason. Moving the name into |
jussielo-amd
left a comment
There was a problem hiding this comment.
Read through the full diff (crates/rocm-core/src/lib.rs, apps/rocm/src/main.rs, the new scenario, and the step definitions) plus the whole review thread. This is a real, long-standing gap — Windows had no equivalent of Unix's try_wait(), so a managed engine that died at startup was reported as a successful rocm serve. The fix is careful where it matters: observe_early_exit only reads GetExitCodeProcess after WaitForSingleObject returns WAIT_OBJECT_0 on the still-open handle from CreateProcessW, which sidesteps both the STILL_ACTIVE ambiguity and the PID-reuse race a later OpenProcess would have, and a failed query degrades to "assume alive" rather than inventing a failure. The service-record wedge fix (retiring the pid-0 "starting" record so a dead launch doesn't permanently block the next rocm serve) is the right companion catch, and I like that it covers both platforms, not just Windows.
I traced the retire_record_on_error wiring by hand: all six fallible steps between the claiming record.write() and the spawn are wrapped on the launch path, try_wait's failure is deliberately left unwrapped with the reasoning recorded at the call sites (a failed liveness query isn't a confirmed death), and the restart path's "only 3 of 11 bails persist the marker" comment is now accurate. Confirmed the new serve-23 scenario and windows-build-and-test/Strix-Halo-Windows lanes are green, and this PR doesn't touch the iGPU/dGPU auto_applicable area at all.
One thing worth a quick look before merging: the MI300X E2E lane failed (serve-vllm-inference and friends) with a vLLM EngineCoreClient crash during the readiness wait — well past the 200ms startup-check window this PR adds, and unchanged on the Unix side regardless. Reads like GPU/environment flakiness on the self-hosted runner rather than anything this PR touches, and it isn't a required check, but worth confirming with a rerun. Also, the branch currently shows a merge conflict against main (just ci.yml workflow drift from commits that landed after the last rebase) — nothing to do with this PR's logic, just needs another rebase before merge.
Really thorough PR description and review trail — the call-site table, the mutation-tested claims, and the willingness to correct earlier overclaims (the Windows-coverage tag mistake, the restart-marker comment) made this fast to verify rather than just taking on faith.
69fa765 to
c06ffbd
Compare
|
Rebased onto main at Re-ran on the rebased head, all clean:
|
|
I pushed fixes for the review at
Verified locally:
Not verified locally: the GPU-gated serve scenarios and Windows runtime behaviour, which the CI lanes cover. The earlier red E2E runs on the dashboard-quit scenarios came from the slow-prompt race in the e2e driver. #713 fixed it on main. |
juhovainio
left a comment
There was a problem hiding this comment.
Reviewed the launch/restart-record hardening, the Windows early-exit detection, and the new serve-26 e2e coverage. The overall direction is solid and the restart/launch record-write and spinner-teardown fixes are real, well-targeted bug fixes. Two things before this lands:
Blocking
- The
serve_cmd.rsspinner fix (foldingstart_managed_serviceandensure_background_helper_running_quietinto one.and_thenso a singlespinner.clear()covers both failure sources) is correct, but it's untested and undocumented. There's no regression test that exercises theensure_background_helper_running_quietfailure branch through this new combinator, and the 439-line PR description never mentions the spinner behavior at all, so a reviewer (or future maintainer) has no way to tell this was intentional versus a refactor side effect. See inline comment. - CI is currently red:
Test (affected crates)fails onworkflow_contract::tests::preflight_runs_the_real_reclaim_assert_clear(xtask/src/workflow_contract.rs:2925). This doesn't match any of the known-flaky patterns in this repo (port races, Windows PATH mutation, self-hosted runner contention) — it's a workflow-contract assertion failure in xtask, which reads as a real regression rather than infrastructure flakiness. Worth confirming before merge.
Non-blocking
- The new/retry e2e step functions (
user_serves_with_lemonade,user_serves_the_same_model_again) don't callensure_serve_port_free()the way sibling steps do. Not a problem today since nothing currently exercises them back-to-back with a port-bound scenario, but worth lining up with the sibling steps if they get reused. persist_spawned_engine_child's documented race (a concurrent stop/restart can leave an orphaned, unrecorded child process) is a pre-existing, deliberate tradeoff per the code's own comments — not introduced by this PR, flagging only for visibility.
No issues found with the restart-path record writes, the Windows STILL_ACTIVE/exit-code handling, or the wait_timeout_millis clamping.
| )?; | ||
| ensure_background_helper_running_quiet(summary_mode)?; | ||
| ) | ||
| .and_then(|report| ensure_background_helper_running_quiet(summary_mode).map(|()| report)); |
There was a problem hiding this comment.
This .and_then is the right fix for the spinner-clear gap (one spinner.clear() now covers both start_managed_service and ensure_background_helper_running_quiet failures), but there's no test exercising the ensure_background_helper_running_quiet failure path through this chain, and the PR description doesn't mention this behavior change. Worth a regression test plus a line in the description so this doesn't read as an unrelated refactor.
There was a problem hiding this comment.
Added in d93e8f13. You were right that the earlier "no unit test is possible" was too quick. Spinner only draws on a terminal, but the logic that matters is the step that runs the two calls, and that step can be tested on its own.
The launch, the helper start and the clear-on-error are now in launch_then_start_helper. It is generic over a small LaunchProgress trait, and serve() passes the real Spinner. The function calls clear() itself, so a call site cannot hand in a clear that does nothing. Three tests use a recording progress line:
a_background_helper_that_fails_to_start_clears_the_launch_progress: the launch succeeds, the helper fails, and the line is cleared exactly once.a_failed_launch_clears_its_progress_and_never_starts_the_helpera_successful_launch_leaves_its_progress_for_the_caller_to_clear: the smoke test still relabels the line, soserve()clears it afterwards.
I checked each one by mutation:
| Mutation | Tests that fail |
|---|---|
Helper called outside the guarded Result, the shape before the fix |
the helper-failure test |
| Clear removed | the helper-failure and failed-launch tests |
| Clear also on success | the success test |
| Helper started even after a failed launch | the failed-launch test |
One limit: if serve() stopped calling the function and went back to bare ?, these tests would still pass. The function would then have no caller outside the tests, and clippy -D warnings would reject it as dead code. So that case is caught by the lint, not by a test.
The PR description now has a section on this.
a53d208 to
8ce98f5
Compare
|
Re:
CI on |
|
Re:
So I split the port half out as Checked locally: serve-18 and serve-26 pass. The local One correction to the premise: not every sibling serve step calls it. The refusal steps and |
|
Re: the orphaned-child race in The race: a stop can confirm between the spawn and the first record write that names the child. That leaves the child running but unrecorded. Two things already cover it:
The follow-up is the one described in my earlier reply on the record-write thread: both paths should stop the child they just spawned when that write finds the record has been taken over. It changes the launch and the restart together, so I've kept it out of this PR's scope. |
Addressed on 8ce98f5 (reviewed a53d208). The red Test (affected crates) was the stale base: the branch is rebased onto ea1d871, which includes #712, and the check is green. The spinner fix is now launch_then_start_helper, with three tests and four mutation checks, and is described in the PR body. The Lemonade serve steps free the serve ports first. The replies are inline and in the PR comments.
juhovainio
left a comment
There was a problem hiding this comment.
Automated review summary
Reviewed via two independent passes: a 4-way file fan-out code review (rocm-core process.rs/lib.rs, service_launch.rs, main.rs+runtimes_cmd.rs, serve_cmd.rs+e2e+docs) and a two-axis mattpocock-skills review (standards, spec-fidelity). No blocking findings.
review-code (4 independent passes)
No finding scored >=70. Non-blocking notes:
watched_detached_spawn_does_not_report_a_child_that_keeps_running(Windows-only, reviewed by inspection) is a legitimate but weak regression guard — it would also pass if the whole watched-spawn feature were reverted. The positive-direction sibling test does properly pin the behavior.observe_early_exit's safety-doc comment saysPROCESS_QUERY_INFORMATIONaccess is required; the only call site already holds a full-access handle fromCreateProcessW. Doc-precision nit only.- Self-disclosed depth limit: of
service_launch.rs's ~90+ tests, 6 load-bearing ones covering TOCTOU-sensitive retire/race paths were individually mutation-traced (all sound); the rest were read in full but not individually traced. - All runnable tests pass on Linux (
cargo test -p rocm --bin rocm,cargo test -p rocm-core --lib wait_timeout_millis);cargo check --testsclean for both crates. Windows-cfg-gated tests reviewed by inspection only (not executable on this host).
mattpocock-skills — standards axis
No hard violations. Commits are Conventional-Commits + DCO-signed-off; new behavior has unit-test and Gherkin (serve-26) coverage per AGENTS.md; docs (architecture.md, testing.md) updated in the same change.
Judgement-call smells (non-blocking):
- Duplicated literal — the e2e trigger env-var name is defined independently in two places:
MANAGED_ENGINE_STARTUP_FAILURE_TEST_ENV(service_launch.rs) vs.MANAGED_ENGINE_STARTUP_FAILURE_ENV/"ROCM_E2E_MANAGED_ENGINE_STARTUP_FAILURE"(serving_steps.rs). A rename on one side would silently desync the e2e hook from the flag it's meant to trigger. Worth a sharedpub(crate) constif convenient, not a blocker. - Parallel test-fixture scaffolding in
main.rsandservice_launch.rsfor the two call sites of the now-unified spawn seam — the production-side duplication this PR removed wasn't mirrored on the test side. LaunchProgresstrait (single production implementor, exists for test-double injection) — consistent with existing repo seam convention (e.g.SpawnManagedEngine), not flagged as a violation.- Minor:
exit_status_from_codetest helper's#[cfg(windows)]/#[cfg(not(windows))]bodies differ by one line — likely unavoidable given platformExitStatusExtdifferences.
mattpocock-skills — spec axis
No missing/partial requirements, no scope creep, no implemented-but-wrong findings. Every concrete claim in the PR description was checked against the diff and matches (flag preservation, shared 200ms settle budget, both-platform retire-before-bail, shared spinner clear-on-error, port-freeing, serve-26 shape). Caveat: targeted spot-check against the description's claims, not an exhaustive line-by-line audit.
CI
Clippy, build-and-test, affected-crate tests, both Windows build-and-test jobs, and most E2E lanes pass. 4 checks show failure, none attributable to this diff:
- Hosted-runner E2E: 2 of the 4 "failures" are
serve-01/serve-02, markedexpect_xfail: trueby the harness itself. The other 2 are TUI dashboard quit-confirm/timing scenarios the diff doesn't touch. - Strix Halo Windows E2E (+ its downstream consolidated-report job): failed on real Windows hardware; logs unavailable. The new Windows logic itself was traced and found sound (exit-code read gated strictly on
WAIT_OBJECT_0, no TOCTOU, safe degrade-to-"alive" on wait failure) — most consistent with this repo's known history of Strix Halo Windows runner flakiness, but not confirmed as flake vs. regression since logs weren't available.
No Jira/ticket ID is referenced in the PR body, commits, or branch name, so no separate ticket-scope check applies.
Automated multi-pass review (review-code + mattpocock-skills:code-review, both axes). Flagging the env-var literal duplication as the one easily-actionable item; everything else is informational.
8ce98f5 to
bc1b68e
Compare
|
Rebased onto
Two new commits on top, from @juhovainio's review:
On the other notes:
Verified locally:
Still left to CI: the GPU serve scenarios, and the Windows half of the startup check on its hardware lane. |
juhovainio
left a comment
There was a problem hiding this comment.
Requesting changes on one item, the rest is non-blocking.
Blocking
- The PR description omits a user-visible behaviour change. Every managed launch and restart now writes the record through an ownership-guarded read-modify-write (
update_managed_record_on_disk,LaunchRecord,record_launch_readiness,persist_spawned_engine_child), not only the failed-startup path. Consequences that are not in the description: the launch's "running" promotion and the child-persist step now bail with a new "changed on disk while its engine ... was starting" error if a concurrent stop, restart or removal intervened, and the readiness verdict is silently not written when ownership is lost. Please describe this in the body, or split it into its own PR so it can be reviewed as the behaviour change it is.
Non-blocking
- Detection only covers an engine that dies within the 200 ms settle window. serve-26 passes only if a fresh
rocm.exerejecting an unknown flag exits inside that window. On a loaded Windows runner with real-time scanning a slow process start would turn the scenario into a 45 s readiness timeout instead of a fast failure, which is a plausible flake. Real engine deaths (runtime or model load) usually take longer than 200 ms, so the practical reach of this check is narrow. Worth stating plainly in the description. - The PR description is stale. It names the scenario serve-25 and quotes an old retry step, and it keeps per-round narration (older head SHAs, test counts, "never executed" next to "confirmed executing"). Please refresh it to the final state of the branch.
- Size and shape: about 4k diff lines and 33 commits under a
fix(serve)title. The spawn-seam and restart refactor and the spinner-clear change inserve_cmd.rsare separate concerns from the Windows death check and would be easiest to review as their own PRs. docs/manual-testing.mddoes not describe the newrocm servefailure behaviour (AGENTS.md asks for observable-behaviour changes to be reflected there).- CI:
E2E tests (Strix Halo, Windows, current)failed before any scenario ran, on a 403 downloading the TheRock tarball from repo.amd.com (infra, not this change). That means serve-26 and the Win32 watched-spawn path have not executed on the Windows E2E lane at this head, so please re-run it and confirm. MI300X and W7900 were still pending when I checked.
Inline comments follow.
| /// Shared by the launch ([`LaunchRecord`]) and the restart | ||
| /// (`update_restarting_record` in `main.rs`). They differ only in what "still | ||
| /// owned" means — see each. | ||
| pub(crate) fn update_managed_record_on_disk( |
There was a problem hiding this comment.
Blocking (see summary): this guarded read-modify-write now sits under every managed launch and restart, and it changes behaviour beyond the failed-startup fix. A stop, restart or removal that lands meanwhile now makes the launch bail with a new error, and the readiness verdict is dropped silently. Please document it in the PR description or split it out.
There was a problem hiding this comment.
Addressed two ways. The restart half of the guarded writes (RestartStartupRecord, the restart's use of the shared seam) moved out to #859, so this PR only changes the launch path. The PR description now describes the launch's guarded writes as the behaviour change they are, in "Every launch write after the claim is now a guarded read-modify-write". It applies to every managed launch, not only failed ones. It covers the new "changed on disk while … launching / starting" failures, says the readiness verdict is not written once the launch no longer owns the record, and says the race is narrowed, not closed.
| return Ok(None); | ||
| } | ||
| update(&mut current); | ||
| current.write()?; |
There was a problem hiding this comment.
Not atomic. The doc comment says the change is applied to the record "as it is on disk now", but this is an unlocked read (read_managed_service_manifest) followed by current.write(), and stop takes no lock. A stop that writes between the read and this write is still lost; the window narrows but does not close. Either soften the comment to say it narrows the window, or lock the manifest. The tests simulate the race by writing before the update, not concurrently.
There was a problem hiding this comment.
Agreed. The read and the write are separate unlocked steps, and stop takes no lock, so a stop that writes between them is still lost. In 44f5e54 the doc on update_managed_record_on_disk says so: it narrows the window (compared with writing back a copy read before the spawn or the readiness wait), it does not close it. The PR description says the same. Locking the manifest would mean stop, rocmd and the liveness refresh all taking that lock, which is a larger change than this PR.
| if recent_lines.is_empty() { | ||
| for _ in 0..5 { | ||
| thread::sleep(Duration::from_millis(120)); | ||
| thread::sleep(poll_interval); |
There was a problem hiding this comment.
When the log stays empty this re-reads on every poll (120 ms interval), adding roughly 600 ms before the error is reported. On Windows the detached spawn redirects nothing and the child only fills the log if it opens it itself, so most Windows failures would pay this wait. Consider skipping the polling on Windows, or polling only when the log file exists.
There was a problem hiding this comment.
I'd keep the poll on both platforms. The launch creates the log before the spawn, so "poll only when the file exists" would always poll. On Windows the child is passed --log-path, and the engine adapters write their own output there (Lemonade through spawn_hidden_console_with_log), so a death after argument parsing can leave a tail a moment after the exit. The ≤600 ms is paid only on this failure path, which used to cost the full 45 s readiness wait before reporting a false success.
| //! | ||
| //! Mechanically relocated from `main.rs` with no behavior change. This module | ||
| //! is the managed-service-spawning tail that was deliberately deferred out of | ||
| //! Relocated from `main.rs`, mechanically and with no behavior change at the |
There was a problem hiding this comment.
This module doc is now self-contradictory: it says "relocated from main.rs, mechanically and with no behavior change" and then goes on to describe behaviour it has since gained, while keeping the old Phase 6a/6b narrative. The same phrasing is in docs/architecture.md, where the bullet is now one very long line. Please rewrite it to describe the module as it is.
There was a problem hiding this comment.
Rewritten in 44f5e54. The service_launch.rs module doc now lists what the module does today: the launch, the spawn seam and startup check, the record bookkeeping, and the key and autostart checks. The relocation history is cut to one sentence. In docs/architecture.md the service_launch.rs bullet now builds on main's corrected text and adds the launch's startup-death check and guarded writes as a separate sentence, instead of one long run-on line.
| /// The spawn and readiness wait [`restart_internal_managed_service`] hands its | ||
| /// restart. | ||
| #[derive(Clone)] | ||
| struct RestartEngineSeams { |
There was a problem hiding this comment.
About 700 lines land in main.rs here (the restart seams, RestartStartupRecord, and their tests), against the AGENTS.md guidance not to grow it while an extraction pass is pending. RestartStartupRecord could live in service_launch.rs next to LaunchRecord. Related smell: restart_engine_seams has a #[cfg(test)] branch inside a production function and allocates Rc<dyn Fn> seams on the production path.
There was a problem hiding this comment.
Resolved by the split. This PR no longer touches apps/rocm/src/main.rs. The stand-in spawn helpers the tests share now live in service_launch.rs's test module. The restart seams, RestartStartupRecord and their tests moved to #859, which puts them in services_text.rs (where main has since moved the restart), not main.rs. The #[cfg(test)] override in restart_engine_seams and the Rc<dyn Fn> seams went with them, so that discussion continues on #859.
| /// release build has no code that reads it. Defined here, rather than in the | ||
| /// binary that reads it, because the E2E suite sets it from another crate: one | ||
| /// constant both sides name cannot drift the way two copies of the string could. | ||
| pub const MANAGED_ENGINE_STARTUP_FAILURE_TEST_ENV: &str = "ROCM_E2E_MANAGED_ENGINE_STARTUP_FAILURE"; |
There was a problem hiding this comment.
A test-only switch exported unconditionally from a production crate. The other e2e hook (ROCM_E2E_LEMONADE_BACKEND_INSTALL_FAILURE) stays a string literal in both crates. Consider gating this behind cfg(test) or a feature, or say why it differs.
There was a problem hiding this comment.
The constant is inert data. The code that reads it is behind rocm/e2e-test-hooks, so a build without that feature contains nothing that reads the variable. Gating the constant behind rocm-core/e2e-test-hooks would mean the e2e crate enabling that feature. Cargo merges features across a workspace build, so that would also switch on rocm-core's other test hooks (hardware_root.rs) in any build that includes the e2e crate. AGENTS.md §3 asks for one shared constant when a value is matched across crates. ROCM_E2E_LEMONADE_BACKEND_INSTALL_FAILURE is still two string literals, and could move to the same pattern in a follow-up.
| } | ||
|
|
||
| #[then("that serve reaches the engine launch again and fails the same way")] | ||
| async fn assert_retry_reaches_the_engine_launch(world: &mut E2eWorld) { |
There was a problem hiding this comment.
This retry step cannot prove the retirement on Unix: as the comment notes, the liveness refresh would let a retry through even with the record unretired, so "reaches the engine launch again" passes either way. The retirement claim rests entirely on the earlier failed status assertion. That is fine, but the scenario's last line reads as stronger than it is.
There was a problem hiding this comment.
Right. On Unix the retry passes even without the retirement, because of the liveness refresh. The proof is the step before it, which reads the record raw and requires failed (and, on Windows, no pid). The scenario comment says so, and the PR description now says it explicitly under serve-36 (renumbered from serve-26, since main took 26 to 35). The last line claims only what it checks: the retry reaches the engine launch again.
nowycondro
left a comment
There was a problem hiding this comment.
🤖 Automated review (Claude Code) · bc1b68e
Scope of this pass: manual static read of the full diff (get_diff, 3985 lines across 10 files) — no Rust toolchain or Windows host available in this sandbox, so nothing was compiled, run, or tested; this is a reading-only review. I read in full: crates/rocm-core/src/process.rs (the new DetachedSpawn/observe_early_exit/wait_timeout_millis Win32 logic), crates/rocm-core/src/lib.rs, and apps/rocm/src/serve_cmd.rs's new launch_then_start_helper. I read the core new abstractions in apps/rocm/src/service_launch.rs and the restart_internal_managed_service_with rewrite in apps/rocm/src/main.rs closely (retire_record_on_error, fail_managed_launch_if_engine_died, watch_managed_engine_startup, LaunchRecord/RestartStartupRecord, update_managed_record_on_disk) but did not line-by-line audit every one of the ~25 new unit tests in those two files, nor the full tests/e2e-cucumber step wiring beyond the new serve-26 scenario and its steps.
Findings: no blocking issues found.
This PR is unusually thorough — the Windows watched-spawn primitive (process.rs) correctly observes the child while hProcess is still open (avoiding the PID-reuse race the PR description calls out), only trusts GetExitCodeProcess after WAIT_OBJECT_0, and degrades to "assume alive" on any ambiguous Win32 result rather than inventing a failure. The record-retirement bookkeeping (retire_record_on_error, fail_managed_launch_if_engine_died, watch_managed_engine_startup, and the parallel LaunchRecord/RestartStartupRecord paths) is internally consistent with its own extensive doc comments: I traced the three outcomes of watch_managed_engine_startup (Observed-dead / Observed-alive / Pending) against ManagedStartupRecord::persist_spawned_child and retire, and the "child the spawn saw die is never put on record, but a child discovered dead by a pending check is retired naming it" asymmetry (which is the crux of avoiding a stale-PID wedge) holds in both the launch and restart call sites.
Two low-confidence, non-blocking observations, offered for completeness rather than as action items:
crates/rocm-core/src/process.rs, inwatched_detached_spawn_does_not_report_a_child_that_keeps_running(new Windows-only test): cleanup islet _ = terminate_process(spawn.pid);— if termination fails the spawnedping.exeleaks for the rest of the CI job. Minor test-hygiene nit, not a correctness issue in shipped code, and the test asserts before any cleanup could matter to its own pass/fail. Confidence: low (pre-existing pattern likely elsewhere in this test file too).- The restart path's
RestartStartupRecord::retireonly callscurrent.reset_for_restart()when!self.child_on_record, so a restart whose engine survives its startup check but whose eventual readiness verdict fails (the documented pre-existing gap: "port binding and model loading happen after the 200 ms window") is never retired/reset at all by this code path — which the PR description explicitly acknowledges as an intentional, pre-existing, out-of-scope limitation shared with Unix and the pre-PRmain. Not a regression; flagging only because it's the one place the diff's own "what this does not close" caveat is load-bearing for understanding blast radius. Confidence: low (matches stated intent, not a bug).
I could not verify: actual runtime behavior of the new Win32 path on a real Windows host (the PR body itself states this is unverified beyond the required CI lane and type/borrow-checking against windows-sys in a scratch crate), and I did not independently re-run or mutate the test suite to confirm the mutation-testing claims in the PR description.
🤖 Generated with Claude Code
nowycondro
left a comment
There was a problem hiding this comment.
🔴 Automated review · pr-review-watcher · bc1b68e
Review — needs work
Full review of the whole change (d59f596...bc1b68e).
Implements ROCMAI-675 ("serve: on Windows a managed engine that dies at startup is reported as success"); ticket retrieved (title/description/status only, status "In Progress"), and the change's scope matches it — the PR does not claim to close it.
Blocking
watch_managed_engine_startup's own record-write failure is new, untested, and its doc comment overclaims what the error contains —apps/rocm/src/service_launch.rs:789,794(call sites), doc at:780.
persist_spawned_child(pid)?(both branches) and the function itself are new in this PR (confirmed viagit show <base>:apps/rocm/src/service_launch.rs, which has no such function — only a baretry_wait()). The doc comment at line 780 claims that when "the write itself fail[s]... the error names its pid," covering both of the two listed failure exits. Tracing the write path:persist_spawned_engine_child→LaunchRecord::update→update_managed_record_on_disk→ManagedServiceRecord::write(). Only the "write refused because something else changed the record" branch actuallybail!s with a message naming the pid (apps/rocm/src/service_launch.rs:473-487); a genuine I/O failure instead propagateswrite()'s own error context, which names only the manifest path (crates/rocm-core/src/service_record.rs:333-350), never the pid. So the comment's claim is wrong for one of the two cases it describes.
More importantly, this is genuinely new behavior this change introduces, and it is untested: no test in the file'smod testsforcespersist_spawned_child's write itself to fail (as distinct from the well-covered "refused because a stop/restart/removal raced it" case, e.g.a_stop_asked_for_while_the_launch_spawns_survives_its_writes). This is not a case where testing it would be impractical — the exact technique needed (fs::create_dir_all(&record.manifest_path)to force the write to fail) is already used in this same file for an analogous case,a_retirement_that_cannot_be_written_is_reported_not_swallowed(line ~2096). A regression that silently swallowed this error, or that routed it throughretire_record_on_error(freeing the claim out from under a child that might still be alive — the opposite of what the surrounding design intends), would go undetected by the current suite.
Confidence 85 · logic/test-coverage · Fix: add a test exercising this branch (reusing the directory-in-place-of-manifest technique) asserting the launch fails, the error is NOT swallowed, and the child is not retired/freed while possibly still alive; narrow the doc comment's "the error names its pid" claim to the branch where it's actually true, or make it true for both.
Non-blocking
- Two more pre-spawn fallible steps have no dedicated failure-path test —
apps/rocm/src/service_launch.rs:1037-1039(managed_service_launcher_path()) and:1040-1054(builtin_engine_serve_http_args(...)). Every other step between the claim-write and the spawn (dir/log creation, missing runtime, invalid key file, the spawn itself) has its own test drivingspawn_managed_engine_childto fail at exactly that point and asserting the record is freed; these two don't. Low severity: both are hard to fail deterministically in practice (one resolves the running binary's own path, the other only fails on a serialization type that essentially can't error), so the gap is a minor completeness item rather than a live risk. - Three new tests in
apps/rocm/src/main.rsexercise an unnecessary ~600ms real-time delay (the production 5×120ms log re-read poll against a log file the test never writes to), making them slower than they need to be. Not incorrect, just avoidable overhead.
Decisions for the author
None
Positive signals
observe_early_exit(crates/rocm-core/src/process.rs) checksGetExitCodeProcessonly afterWaitForSingleObjectconfirmsWAIT_OBJECT_0, so theSTILL_ACTIVE/exit-code-259 ambiguity genuinely cannot arise, and the liveness check is done entirely through the already-open process handle rather than by re-opening by PID — the PID-reuse race the design claims to avoid is actually avoided, traced concretely.- The "retire the claim record on every fallible step before the spawn" discipline (
retire_record_on_error) is applied completely and correctly across all seven pre-spawn steps inspawn_managed_engine_child, each backed by its own real end-to-end test driving the public function rather than a generic smoke test — a notably thorough and precisely-scoped test suite overall (three independent reviewers, briefed to find gaps, converged on the same single real gap above). - The e2e scenario's final assertion ties to the exact
log_pathrecorded on disk rather than a loose.log-suffix match, and its retry step reuses the identical engine+model key, so it genuinely exercises the record-retirement fix rather than passing for an unrelated reason.
Deployment notes
- Two self-hosted CI lanes are currently red on this exact head (bc1b68e):
E2E tests (Strix Halo, Windows, current)(fails in ~2m41s producing no test results) andE2E tests (MI300X, current)(and theE2E consolidated report (self-hosted)that depends on them). This is the one CI lane that exercises the Windows half of this PR's own fix, so it matters that it hasn't re-confirmed at this head. However, comparing against five other recently-merged, unrelated PRs (#762, #755, and others) shows the identical failure signature and duration recurring independently of this change — strong evidence this is a pre-existing self-hosted-runner flake rather than a regression this PR introduces, so it is not raised as a blocking finding. Whoever merges should still get a green run of the Windows lane at this head (or an explicit maintainer waiver) before relying on the PR's own claim that the scenario passed on Windows — that claim was confirmed at two earlier commits in this branch's history, not at the current head. - User-observable behavior change (already disclosed in the PR body): a Windows managed launch now holds the cross-process launch lock ~200ms longer than before, and now fails fast (within the existing 200ms settle window) instead of waiting up to 45s to misreport success.
- Explicitly disclosed, legitimate gap: the restart path's startup-death handling has unit coverage but no end-to-end scenario coverage (blocked on a separate dependency per the PR text).
What this covered
Every file in the diff, read completely, with two deliberate exceptions stated below: crates/rocm-core/src/lib.rs, crates/rocm-core/src/process.rs, apps/rocm/src/service_launch.rs, apps/rocm/src/serve_cmd.rs, apps/rocm/src/runtimes_cmd.rs, tests/e2e-cucumber/features/model_serving.feature, tests/e2e-cucumber/tests/e2e/serving_steps.rs, docs/architecture.md, docs/testing.md were each read in full via five independent fan-out workers (one per coupled file-group) plus my own direct reads for the two doc files. apps/rocm/src/main.rs is 22,046 lines total with only ~1,300 of them touched; reading the entire file was infeasible, so its reviewer was scoped to the touched production region (restart_internal_managed_service/_with, record_restarted_child) plus every new/changed test, read completely with surrounding context — this is a deliberate, stated narrowing, not a skip. d59f596...bc1b68e.
Agent-instruction adherence checked against this repo's AGENTS.md (411 lines, read in full): the user-observable-behavior-needs-a-scenario rule (§3) is satisfied by the new serve-26 scenario; module-placement and shared-seam documentation in docs/architecture.md matches the actual code; commit signing/DCO is confirmed via the green "Commit signatures + sign-off" CI check rather than local verification (this sandbox's git lacks the SSH allowed-signers file needed to verify locally). Commit history (33 commits) was read for message quality and structure — each commit is atomic and descriptive, consistent with iterative response to earlier review rounds disclosed in the PR body, not incremental noise.
Did not run: history pass was limited to confirming (via git show <base>:<path>) that the functions in the one blocking finding are new rather than pre-existing, rather than an exhaustive blame-pass over every touched region. Prior-changes pass did not run — no prior review-comment history was read, by this method's own rule (discussion is never read during a review). CI status was read via gh pr view --json statusCheckRollup and gh pr checks, taken at face value, and cross-checked against five other recently-merged PRs' checks to distinguish a pre-existing flake from a regression (see Deployment notes).
Not reconciled against any discussion on this PR — none was read, per this method's rule that discussion is never part of a review.
Add spawn_detached_no_inherit_watching_startup next to spawn_detached_no_inherit. It keeps the same creation flags and handle inheritance (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, no inherited handles). It also waits on the child's process handle for a bounded settle window before closing it, and returns the exit code as DetachedSpawn::early_exit_code when the child has already exited. The check runs while the CreateProcessW handle is still open, so it does not carry the PID-reuse race that re-opening the process by pid would. GetExitCodeProcess is read only after WAIT_OBJECT_0. Any ambiguous result is reported as "still running". Also export MANAGED_ENGINE_STARTUP_FAILURE_TEST_ENV, the one name the rocm binary and the E2E suite share for the scripted startup death. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
…ROCMAI-675) On Windows, `rocm serve --managed` reported success when the engine process had already died during startup. The Windows spawn returned a bare pid and nothing checked it, so the user was left waiting on a server that would never come up. Linux already failed the launch, but left the record claiming the engine and model, so a retry was refused as already running. - The launch now spawns through spawn_managed_engine_process and checks the child with watch_managed_engine_startup, on both platforms within the same 200 ms settle window. A death fails the serve and names the engine's own log, with a tail of it when the engine wrote one. - A launch that fails before its spawn, or whose engine is seen dead at startup, retires its record as "failed". The next `rocm serve` for the same engine and model then launches again. - Every launch write after the claim goes through LaunchRecord, a guarded read-modify-write of the record on disk (update_managed_record_on_disk). If a stop, restart or removal changed the record while the launch ran, the launch fails with "changed on disk ..." instead of overwriting it. A readiness verdict for a record the launch no longer owns is not written. The read and write are not atomic, so this narrows the race rather than closing it. - The serve spinner is cleared on every path before anything else prints, including a failed background-helper start. - An e2e-test-hooks build can script the engine's startup death through ROCM_E2E_MANAGED_ENGINE_STARTUP_FAILURE. An engine that dies after the settle window, while loading its runtime or model, is still reported as starting, as before. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
serve-36 scripts the managed engine's startup death. It checks that the serve fails naming the engine's own log, that the record left on disk reads "failed" (and, on Windows, names no pid), and that serving the same model again reaches the engine launch again. It carries no host tag, so it runs on every lane, including the Windows one, where the watched spawn executes. The Lemonade serve step now frees the shared serve ports first, as the engine-starting serve steps do. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
bc1b68e to
4f6dd4d
Compare
|
Fixed in 44f5e54.
The restart's side of this check moved to #859. |
nowycondro
left a comment
There was a problem hiding this comment.
🤖 Automated review (Claude Code) · 4f6dd4d
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 (diff 9875940...4f6dd4d). Implements the Windows startup-death detection and record-retirement fix described in ROCMAI-675; the PR's own description explicitly scopes the restart path out to a stacked follow-up (#859).
Blocking
-
A readiness-verdict write silently returns stale, pre-wait record data when the service record was fully removed mid-launch —
apps/rocm/src/service_launch.rs:588-608(record_launch_readiness).
When the guarded write finds the launch no longer owns the record (recorded == false), it falls back toservices_text::read_managed_service_manifest(...).map_or(launch.record, |(current, _)| current). That re-read fails (returnsErr) when the manifest file is gone entirely — reachable via a concurrentrocm services removeduring the up-to-45s readiness wait — andResult::map_orsilently discards theErrand returnslaunch.record: the stale, pre-wait copy (e.g. still"running", pointing at paths that no longer exist). This contradicts the function's own doc comment ("the record as it is now is returned untouched") and is surfaced straight to the user/dashboard viaManagedLaunchReport. The threelaunch_readiness_write_*tests cover stop/restart/liveness-refresh but not full removal, so nothing catches it. Every other write-after-claim site in this PR (persist_spawned_engine_child, the "running" promotion) fails loudly instead of silently reporting stale data.
Confidence 78 · logic · Fix: on a failed re-read, propagate that the record is gone (or treat absence as its own terminal status) instead of defaulting to the stale in-memory copy. -
The new e2e scenario (serve-36) has no corresponding entry in
docs/testing.md— AGENTS.md §5 requires updatingdocs/testing.md"in the same change" when observable behavior changes, "not leave user-facing docs for a follow-up."docs/testing.mdalready documents every other scenario with comparable platform/tag nuance in this same feature (serve-24, serve-28 through serve-35), each with the same kind of "why this tag, why this lane" rationale that serve-36's own Gherkin comment carries. serve-36 is the one scenario of this shape missing adocs/testing.mdentry.
Confidence 72 · mechanical · Fix: add a short paragraph for serve-36 alongside the serve-28..35 entries, mirroring the existing style.
Non-blocking
None.
Decisions for the author
- The guarded read-modify-write "narrows, does not close" the record race — tradeoff, already disclosed in both the PR description and the code's own doc comments (
update_managed_record_on_disk,LaunchRecord). Verified the claim against the code: the read and the write are indeed separate, unlocked steps, and a concurrent stop can still land between them. A full manifest lock would close it but is a larger change; confirm narrowing is the intended scope for now. - A child whose spawn is observed alive, but then loses the ownership race to a concurrent stop/restart/removal before
persist_spawned_engine_childwrites, is left running with nothing on record thatrocm services stopcan ever reach (watch_managed_engine_startup/persist_spawned_engine_child). This is explicitly disclosed in the function's own doc comment and is a deliberate choice (retiring here would be worse — see the doc's reasoning), not a silent gap. Worth confirming the error message naming the orphaned pid is considered sufficient mitigation.
Positive signals
LaunchRecord::update's ownership check correlates the on-disk record to the launch's own copy by actual identity (supervisor_pid+supervisor_start_ticks+ liveness), never by position or a blind overwrite — exactly avoiding the id-vs-position pitfall this kind of concurrent-write code commonly falls into.ManagedStartupRecord/retire_record_on_error/fail_managed_launch_if_engine_diedgive Windows (Observed) and Unix (Pending) startup checks one shared choke point, so both platforms produce identical user-facing errors and record bookkeeping instead of duplicated, divergent logic.scripted_managed_engine_startup_failure()has twocfg-gated definitions (real check vs. aconst fn false), so a release build (withoute2e-test-hooks) provably contains no code path that reads the test-only env var;release.ymlindependently confirms the shipped binary is built without that feature.- The regression tests for
spawn_managed_engine_childinject a stand-in spawn at the real call site (not a mock several layers removed), pinning what the record looks like at each step of a dead-at-startup, ambiguous-check, and racing-stop/restart/removal launch — traced several of these by hand and confirmed they'd fail if the specific branch they target were broken.
Deployment notes
- The author's own PR description states plainly that the Windows build and Win32 path are "not verified locally" and that the Windows E2E lane "has not yet run green at this head" — confirmed independently: as of this review, no CI/CodeQL/E2E workflow run exists yet for commit
4f6dd4dat all. The most recent available signal on this branch (commitbc1b68e, Oct 26) showed both the Windows self-hosted E2E lane and the MI300X lane failing, but that commit predates a rebase onto a much newermain(the diff between it and the current head touches ~260 unrelated files), so that failure cannot be attributed to the current head with any confidence either way. This fix should not be considered verified on Windows — the one platform it targets — until its own CI/E2E run is green at this commit. - Restarting a managed service (
rocm services restart, and the assistant's restart tool) still spawns via the old, unwatched path on Windows; the exact bug this PR fixes forrocm serve --managedis still open for restarts until the stacked PR (#859) merges. Already disclosed in the PR description, repeating here only as an operational heads-up.
What this covered
Every file in the 9-file diff was read in full (not just diff hunks), plus surrounding/consumer code needed to judge it in place: engine_ipc.rs, engine_runtime_select.rs, engines/lemonade/src/lib.rs and process.rs, and services_text.rs, reached via targeted identifier searches (spawn_managed_engine_process, MANAGED_ENGINE_STARTUP_FAILURE_TEST_ENV, scripted_managed_engine_startup_failure, spawn_detached_no_inherit) to confirm the managed-launch spawn is a single level (not routed through the separate, unrelated engines/lemonade::launch_service/spawn_serve_http_background path) and that the restart path's continued use of the old spawn matches the PR's stated scope.
apps/rocm/src/service_launch.rs (the largest, most important file) was covered by one genuinely independent fan-out worker, reading the entire file and entire diff blind, in addition to my own direct reading of its production code paths. The other four groups — crates/rocm-core/src/process.rs+lib.rs, apps/rocm/src/serve_cmd.rs+services_text.rs, the e2e feature+step files, and the two docs files — were reviewed single-context by the consolidating pass after four further fan-out attempts were rejected by the harness ("concurrent subagent limit reached... do not retry"), a shared-capacity condition outside this review's control. Those four passes are therefore not independent of this write-up; weight them accordingly.
Ticket ROCMAI-675 was retrieved directly from Jira: "serve: on Windows a managed engine that dies at startup is reported as success," status In Progress, description matching this PR's stated symptom. CI status was read live from the GitHub Actions API for the current and most recent prior commit on this branch (see Deployment notes), not assumed or re-derived from workflow YAML.
Did not run: the "prior changes" pass (this method does not read earlier review threads) and no local cargo build/cargo test (no Rust toolchain in this review environment) — test-quality conclusions above are argued from reading the assertions against the implementation, not from executing them. Not reconciled against any PR discussion or earlier review round.
🤖 Generated with Claude Code
Symptom
On Windows,
rocm serve --managedreported success when the managed engine process had already died during startup. It exited 0 and printed a deployment summary withreadiness: starting. The user was left waiting on a server that would never come up, and nothing pointed at the reason.On Linux the same death already failed the serve, with a tail of the engine's service log. But the failed launch's record kept claiming the engine and model, so the next
rocm servefor the same pair was answered with "already running" and spawned nothing.Root cause
rocm_core::spawn_detached_no_inherit. That returns a bare pid, with noChildand no exit check. After the spawn the launch waited up to 45 s for HTTP readiness, recorded the status and returnedOkeither way. A dead engine looked exactly like a slow one."starting"with supervisor pid 0. A failed launch never moved it out of that state."starting"counts as live, and pid 0 is skipped by the liveness refresh, so the record blocked every later serve of that engine and model for good.Fix
Watched spawn (Windows).
crates/rocm-coregainsspawn_detached_no_inherit_watching_startup.CreateProcessWreturned, for a bounded window, before closing it. It returns the exit code (DetachedSpawn::early_exit_code) if the child has already exited.GetExitCodeProcessis read only afterWAIT_OBJECT_0. Any ambiguous result is reported as "still running", so the check can fall back to the old behaviour but never invent a failure.One startup check for the launch (
apps/rocm/src/service_launch.rs). The launch now spawns throughspawn_managed_engine_processand settles the child withwatch_managed_engine_startup:try_wait.MANAGED_ENGINE_STARTUP_SETTLE) and produce the same error:managed engine exited immediately with status …; inspect <log>. It names the engine's own service log, with a tail of it when the engine wrote one.rocm servewaiting for that lock waits up to 200 ms longer.A failed launch frees its engine and model.
"failed". The nextrocm servefor the same engine and model launches again.try_waitthat cannot answer leaves the child on record as"starting"rather than retiring it. The child's state is unknown, and freeing the engine and model could put a rival engine on its port.Every launch write after the claim is now a guarded read-modify-write. This is a behaviour change beyond the startup-death fix, and it applies to every managed launch, not only failed ones.
LaunchRecordandupdate_managed_record_on_disk: putting the child on record, retiring the record, the promotion to"running", and the readiness verdict.…changed on disk while it was launching … so the engine it started (pid N) was not recorded on it". If it happens after the startup check, the error ends "… so the launch did not mark it running". Before, the launch overwrote whatever had been written.The serve spinner is cleared on every failing path before anything else prints, including a failed background-helper start (
launch_then_start_helperinserve_cmd.rs).E2E hook. A build with
rocm/e2e-test-hookscan script the engine's death withROCM_E2E_MANAGED_ENGINE_STARTUP_FAILURE.rocmreally exits non-zero at startup.rocm_core::MANAGED_ENGINE_STARTUP_FAILURE_TEST_ENV, so the binary and the e2e suite cannot drift apart.What this does not close: the 200 ms detection limit
The check only catches an engine that exits within the 200 ms settle window. An engine that dies later is still reported as starting, after the readiness wait, exactly as before on both platforms. That covers most real failures: a runtime or model load error usually takes longer than 200 ms.
So in practice this catches immediate deaths: a bad argument, a missing binary or DLL, a crash on start. It does not make the launch report every engine that never becomes ready.
serve-36 relies on a fresh
rocm.exerejecting its argument within 200 ms. On a heavily loaded Windows runner with real-time scanning, a slow process start could push that past the window. The scenario would then fail on its first assertion (the serve exits 0 instead of failing), not pass by accident. Reporting every engine that never becomes ready is a separate change and is not attempted here.The restart path moved to #859
Restarting a managed service (
rocm services restart, and the assistant's restart tool) still uses its own spawn code here. On Windows that is a bare pid with no startup check. #859 moves the restart onto the same spawn seam and startup check, with its own guardedRestartStartupRecord, inservices_text.rs. It is stacked on this PR and stays a draft until this merges. #581, the end-to-end restart scenario, will be restacked on #859.This PR no longer touches
apps/rocm/src/main.rs.Tests
Unit tests (
service_launch.rs). These drivespawn_managed_engine_childwith an injected stand-in spawn, so each launch outcome is pinned at the real call site without starting an engine:a_child_that_cannot_be_recorded_is_neither_checked_nor_retireda_launch_whose_child_cannot_be_recorded_fails_with_the_write_errorWindows-only unit tests (
process.rs, run on the Windows CI job):watched_detached_spawn_reports_a_child_that_exits_immediatelywatched_detached_spawn_does_not_report_a_child_that_keeps_runningwait_timeout_millis_keeps_the_startup_wait_boundedScenario
serve-36(@id:serve-managed-engine-dies-at-startup):failed. On Windows it must also name no pid.failedassertion is what proves it.E2E tests (Strix Halo, Windows, current), the only lane where the watched spawn runs.Manual check:
docs/manual-testing.md§4 has steps for it.Gate, run locally on Linux at this head:
cargo fmt --checkandcargo clippy --workspace --all-targets -- -D warnings: clean.cargo test --workspace --all-targets: 4914 passed, 3 failed.services_text::tests::stop_of_a_container_engine_is_unconfirmed_when_the_engine_stop_failsalso fails onmainon this host, because itsdockeris a podman shim.build_metadatagit_watch_paths_*tests fail on agit commitinside a scratch repository. That looks like this host's commit-signing setup; this PR does not touchbuild.rsorapps/rocm/tests.Not verified locally: the Windows build and the Win32 path. This host has no Windows target, so the Windows CI job and the Windows E2E lane are the check. That lane has not yet run green at this head.