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 |
…tartup On Windows, `rocm serve` reported success (exit 0, `readiness: starting`) when the managed engine process died during startup. The same failure on Linux produced a clear error carrying a tail of the child's service log. The two platforms spawn the managed engine differently. The Unix branch gets a `std::process::Child`, waits 200 ms, and `try_wait()`s it: a child that has already exited fails the launch with `managed_engine_startup_failure_detail`, which tails the child's log into the error. The Windows branch calls `spawn_detached_no_inherit`, which returns a bare PID with no handle and no liveness check at all. The caller then waited up to 45 s for HTTP readiness, recorded the status and returned `Ok`, so a dead engine was indistinguishable from a slow one. Add `spawn_detached_no_inherit_watching_startup` alongside the existing helper. It keeps the creation flags and handle-inheritance behaviour identical, so the child stays detached, and performs a bounded `WaitForSingleObject` plus `GetExitCodeProcess` while the process handle from `CreateProcessW` is still open. Doing it there rather than re-opening the process by PID afterwards avoids a PID-reuse race: by the time a later `OpenProcess` ran, the PID could name an unrelated process. A read failure reports "still running", so the check can only ever degrade to the previous behaviour. The launch path and the restart path both adopt it and now produce the same `managed_engine_startup_failure_detail` error as Unix, from a shared 200 ms budget. The blind `thread::sleep(200 ms)` that followed each Windows spawn is removed: the checked wait replaces it, so the wall-clock delay before the readiness poll is unchanged. It does now fall inside the launch lock, as it already did on Unix. Failing the launch exposed a second problem, and both platforms had it: the service record is written before the spawn to claim the GPU, so it still reads `"starting"` with a `supervisor_pid` of 0. Pid 0 is filtered out of the liveness refresh, so nothing demotes such a record, while `"starting"` counts as live — abandoning it would make the idempotency guard report the corpse as already running and refuse every later `rocm serve` for that engine and model. Both arms now retire the record as `"failed"` first, which is what the restart path already did. The remaining `spawn_detached_no_inherit` call sites are deliberately left alone, both because they are already symmetric across platforms: the background automation daemon autostart checks liveness on neither platform and logs rather than propagates its spawn result, and the Lemonade serve-http background spawn drops its `Child` immediately on Unix without a `try_wait()`. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The branch that turns a detected startup death into a failed `rocm serve` was the point of the change and nothing exercised it: both bail blocks were `#[cfg(windows)]`, so no Linux test could reach them, and no test on the Windows lane drives `spawn_managed_engine_child`. Detection was proven in `rocm-core` and record retirement was proven in isolation, but deleting the blocks that join them left every runnable test green. Collapse the four near-identical copies of that check — two Windows, two Unix — into `fail_managed_launch_if_engine_died`, which takes an `Option<ExitStatus>` and is therefore platform-neutral and testable everywhere. Windows reaches it through `managed_startup_exit`, which is the only part that stays `cfg`-gated. Also close an adjacent hole of the same class: a spawn that fails outright returned straight past the retirement, stranding the pre-spawn record with its `"starting"` status and pid 0 — the exact wedge the early-exit path was fixed to avoid. `spawn_or_retire_record` retires the record on that path too while still surfacing the spawn's own error. Tests fail against the mutations that motivated them: neutering the bail fails only the dead-engine test, and dropping the spawn-failure retirement fails only the spawn-failure test. Scope the `mark_managed_launch_failed` doc comment to the launch path, since the restart callers hold a pre-existing record with a merely stale pid. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Review round 2 showed the previous round's unit tests pinned the shared startup check but not the wiring to it: deleting both Unix call sites — the two that actually execute on Linux — left all 765 binary tests green. The earlier claim that the exposure was Windows-only was wrong; it was platform-wide. AGENTS.md §3 also requires a scenario for behaviour a user can observe, and this changes the exit code and error text of `rocm serve`. Add scenario serve-22, driven by an `e2e-test-hooks` switch that scripts the engine's death. The child is the real `rocm` binary, really spawned and really dead — only its arguments are swapped for ones clap rejects, so the launch takes the path a broken engine takes. Reading the switch in the parent keeps the child free of test-only behaviour. Like the existing Lemonade seam it waives the no-GPU pre-flight, and it waives engine preparation too, so the scenario reaches a real spawn on the ungated lane without GPU hardware or a runtime download. An unrecognised flag rather than an unrecognised subcommand: the CLI parses unknown subcommands as natural-language requests and exits 0. Verified the scenario pins what the unit tests could not — deleting the Unix launch call site turns serve-22 red. Being ungated it also runs on the Windows and WSL2 lanes, so it covers the call sites the unit tests cannot reach at all. Also close the wedge paths review found beside it: six fallible steps between `record.write()` and the spawn returned past the retirement, stranding the same pid-0 "starting" record. They now go through `retire_record_on_error`, which replaces `spawn_or_retire_record` and takes an evaluated Result so it can wrap the whole prefix. It drops the retirement error, and `fail_managed_launch_if_engine_died` now does too, so a failed record write can no longer displace the engine's own exit status and log tail. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
… mock one serve-22 was tagged `@requires-no-gpu`, which is skipped on any host that HAS a GPU. That excluded every GPU lane including Strix Halo Windows — the one place the Windows half of the startup check can actually execute — so the scenario only ever ran on the Linux mock lane, and the claim that it covered the Windows call sites was wrong. The tag was wrong on its own terms too: the premise is an engine that dies at startup, not a host without a GPU. The test hook already waives the no-GPU pre-flight and engine preparation, which is what makes the premise hold whatever hardware the host has, so the scenario needs no host tag at all. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Review round 3 caught a guarantee the code did not keep. The comment said every fallible step from the record write "to a live child" went through `retire_record_on_error`, and the helper's own doc stated it as a rule, but two steps on the Unix path used a bare `?`. The two are not the same case, and the fix is asymmetric: `attach_background_stdio` is pre-spawn, so no child exists and retiring is unambiguously right. Now wrapped, at the launch and restart sites both. `try_wait` is post-spawn and deliberately stays unwrapped. A failed liveness query means the child's state is unknown, not that it died; retiring there would release the engine + model to the next `rocm serve` while this child may still hold the port. The rule is narrowed to end at the spawn instead, with the reason recorded at the helper and at both query sites, because the next person adding a fallible step is who the rule is for. `fail_managed_launch_if_engine_died`'s doc no longer implies the platforms agree about a failed query. They agree about an observed death; on a query that fails Windows degrades to "assume alive" and Unix bails. Also correct the restart marker comment: it claimed a bail leaves the unconfirmed-stop marker set, which stopped being true once the bail paths started retiring the record, since retiring writes it. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The comment above the `stop_requested_unix_ms` clear asserted that every bail below retires the record, so a failed restart always leaves the key in place. Only the post-spawn bails do that; the fallible steps between the clear and the spawn return with a bare `?` and never write the record, so the on-disk marker stays set and the next liveness refresh reclaims the endpoint key. The marker drives key lifetime, so a maintainer reasoning from that sentence would have drawn the wrong conclusion for most of the failure paths. Qualify the span to the bails that actually persist the clear and state the earlier-bail behaviour, which is correct on its own terms: a restart that never began leaves the stop standing. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
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 |
…sleeping
Follow-ups from review on the startup-death coverage.
The scenario's last check was `output.contains(".log")`, which any
incidental mention of a log suffix satisfies. Read the service record
the failed launch leaves behind and assert the output names that exact
`log_path`. It is the path `rocm` itself recorded, so a Windows 8.3
short form cannot make the comparison disagree with itself. Checked by
mutation: a build that names some other `.log` file keeps the old check
green and fails this one, which reports the real path it expected.
The no-log detail test walked the real five-iteration re-read loop,
spending about 600 ms in sleeps to prove a string. Inject the poll
interval through a `_polling` variant: production keeps 120 ms through
`MANAGED_ENGINE_STARTUP_LOG_POLL_INTERVAL`, the test passes zero, and
the loop still runs every iteration. 0.66 s down to under 10 ms.
Also rename the shared When step, which still read as the old Lemonade
preparation failure, and note why the two platforms reach the assertion
through different branches of the message: on Unix the child's stdio is
redirected into the log, so it carries a tail; on Windows it is not, so
only the no-tail branch runs. Naming the path is what both share.
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
|
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 |
Symptom
On Windows,
rocm servereported success — exit code 0 and a deployment summaryshowing
readiness: starting— when the managed engine process had already diedduring startup. The user saw a "launched, still warming up" message for a server
that would never come up, and nothing pointed at the reason.
The identical situation on Linux fails the command with a clear error that
includes a tail of the engine's own service log.
Root cause
spawn_managed_engine_childinapps/rocm/src/main.rsspawns the enginedifferently per platform:
cfg(not(windows))branch gets astd::process::Child, sleeps 200 ms andcalls
try_wait(). A child that has already exited fails the launch viamanaged_engine_startup_failure_detail, which tails up to 80 lines of thechild's log into the error.
cfg(windows)branch callsrocm_core::spawn_detached_no_inherit, whichreturns a bare PID. There is no
Child, and no liveness or exit check ofany kind.
After the spawn the parent waits up to 45 s for HTTP readiness, records the
status and returns
Ok(..)regardless, so on Windows a dead engine wasindistinguishable from a slow one. This is long-standing rather than a
regression — the Windows spawn line dates to the initial import.
Fix
crates/rocm-core/src/lib.rsgainsspawn_detached_no_inherit_watching_startupnext to the existing helper. It keeps the creation flags and handle-inheritance
behaviour byte-for-byte (
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, no inherited handles), so thechild is still fully detached and still outlives the CLI. What it adds is a
bounded
WaitForSingleObjectplusGetExitCodeProcesswhile the processhandle returned by
CreateProcessWis still open, before that handle isclosed.
Doing the check there, rather than re-opening the process by PID afterwards, is
deliberate: a later
OpenProcess(pid)would carry a PID-reuse race, becauseWindows recycles PIDs and the PID could by then name an unrelated process. The
Unix side does not have that problem — it holds a
Child— and this keepsWindows equally race-free.
GetExitCodeProcessis only consulted after the waitreturns
WAIT_OBJECT_0, so theSTILL_ACTIVE(259) ambiguity cannot arise; ifthe exit code still cannot be read the helper reports "still running", so the
check can only ever degrade to today's behaviour, never invent a failure.
Both platforms now use one shared 200 ms budget
(
MANAGED_ENGINE_STARTUP_SETTLE) and produce the samemanaged_engine_startup_failure_detailerror, so the child's log reaches theuser either way.
What this does and does not close. The check is a bounded window, not a
guarantee. An engine that survives the 200 ms settle and dies afterwards is
still reported as a successful launch, because
start_managed_servicerecordsthe readiness status and returns
Okregardless — so on a loaded machine anormally-fast death can slip past. That limitation is pre-existing on Unix and
unchanged by this PR; what the PR does is give Windows the same window Unix
already had, so the two platforms fail identically. Closing the wider class
(reporting success for an engine that never becomes ready) is a separate change
and is not attempted here.
The blind
#[cfg(windows)] thread::sleep(Duration::from_millis(200))thatfollowed each Windows spawn is removed: the checked wait is the same duration at
the same point, so the wall-clock delay before the readiness poll is unchanged.
It does now fall inside the launch lock rather than just after it — which is
exactly where Unix already paid it, so this aligns the two rather than adding
wall-clock cost to a single launch. It is not free for concurrent ones,
though: a Windows launch now holds the cross-process launch lock ~200 ms longer,
so a second
rocm serveracing for the same lock waits that much longer tostart. Bounded and deadlock-free, and small next to the multi-second readiness
wait the lock already guards, but worth stating rather than leaving implied.
Second defect this exposed
Making the launch fail surfaced a latent problem that affected both
platforms. The service record is written before the spawn, to claim the GPU
for concurrent auto-selection, and at that point it still carries the
constructor's
"starting"status and asupervisor_pidof 0. Bailing out andleaving it that way wedges the service permanently:
recorded_service_pidsfilters out pid 0, sorefresh_managed_service_runtime_livenessfinds no tracked pid and never demotes the record;managed_service_is_livecounts"starting"as alive;existing_live_managed_servicekeeps returning the corpse, and the idempotency guard makes every laterrocm servefor that engine + model reportAlreadyRunninginstead of relaunching.On Windows this was previously masked — the code never bailed there, so the
record got a non-zero (dead) pid and the liveness refresh self-healed it to
"stopped". Both arms now retire the record as"failed"before bailing, whichis what
restart_internal_managed_servicealready did.The
spawn_detached_no_inheritcall sitesspawn_managed_engine_child— managed launch, also used by the attached--verbose/--foregroundpath (apps/rocm/src/main.rs)restart_internal_managed_service— managed restart (apps/rocm/src/main.rs)try_wait()sitting a few lines below is inside thecfg(not(windows))branch, so Windows restarts silently "succeeded" too.ensure_background_helper_running_quiet— automation daemon autostart (apps/rocm/src/main.rs)spawn_serve_http_background— Lemonade serve-http background spawn (engines/lemonade/src/lib.rs)Childimmediately without atry_wait().spawn_hidden_console_no_inheritwas checked for the same shape and has nocallers outside
rocm-core, so there is nothing to fix. Its siblingspawn_hidden_console_with_logis used by the Lemonade adapter, but that is adifferent lifecycle — the caller keeps and polls the result — and is out of
scope here.
Tests
In
crates/rocm-core/src/lib.rs,#[cfg(windows)], running on the requiredwindows-build-and-testlane:watched_detached_spawn_reports_a_child_that_exits_immediately— spawns aprocess that exits with code 7 and asserts the spawn reports it.
watched_detached_spawn_does_not_report_a_child_that_keeps_running— spawns along-lived process and asserts it is not reported as a startup failure, so
the check cannot reject healthy launches.
In
crates/rocm-core/src/lib.rs, running everywhere:wait_timeout_millis_keeps_the_startup_wait_bounded—INFINITEisu32::MAX, so a saturating conversion of a long budget would silently turn thebounded check into a blocking join. This pins the clamp one below it.
In
apps/rocm/src/main.rs, running everywhere:a_dead_engine_fails_the_launch_and_frees_the_engine_for_the_next_serve—the user-visible fix end to end at the unit level: an engine that already
exited must fail the launch, name its log, and release the engine + model.
a_live_engine_leaves_the_launch_running— the converse, so the check cannotreject a healthy launch or retire its record.
a_spawn_that_never_started_frees_the_engine_for_the_next_serve— covers thespawn-failure path described below, and asserts the spawn's own error is what
survives.
a_failed_launch_record_stops_blocking_the_next_serve— regression test forthe record-wedge above. Its precondition assertion demonstrates that a pid-0
"starting"record really does read as live, then shows that retiring it as"failed"frees the engine + model for the next launch.managed_engine_startup_failure_detail_carries_the_child_log_tailandmanaged_engine_startup_failure_detail_without_a_log_still_points_at_it—cover the error both platforms now build, including the empty-log case. Note
these are new coverage on Windows only: the function was
cfg-gated awaythere before this PR, so on Linux they characterise unchanged code and pass
with the rest of the fix reverted.
In
apps/rocm/src/main.rs,#[cfg(windows)]:managed_startup_exit_reads_the_watched_exit_code— the one remainingcfg-gated step, converting the watched spawn'su32into anExitStatus.Why the wiring is tested, not just the two halves
The first review round made the fair point that detection and record retirement
were each proven in isolation while the branch joining them — the thing that
actually turns a dead engine into a failed
rocm serve— was reachable fromneither side, so deleting it left every runnable test green. It was
cfg-gatedto Windows in two places, which put it out of reach of any Linux test and of
every test that does run on the Windows lane.
Rather than leave that to an end-to-end test, the four near-identical copies of
the check (two Windows, two Unix) are now one function,
fail_managed_launch_if_engine_died, which takes anOption<ExitStatus>and istherefore platform-neutral and directly testable on every lane. Only the
u32→ExitStatusconversion stayscfg-gated, and it has its own test.Mutation-checked, each failing only its own test:
fail_managed_launch_if_engine_died(the review's mutation)a_dead_engine_fails_the_launch_and_frees_the_engine_for_the_next_servefailsretire_record_on_errora_spawn_that_never_started_frees_the_engine_for_the_next_servefailsThat statement was corrected in review. An earlier revision of this section
claimed the residual gap was Windows-only. It was not: deleting both Unix call
sites — the two that execute on Linux — also left all 765 binary tests green. The
helper was pinned; the wiring to it was unpinned on every platform. Scenario
serve-23 below closes that for the launch path, and is verified to fail when a call
site is deleted.
To be explicit about what the unit tests do and do not prove: none of the four
real call sites of the new helpers — two on the launch path, two on the restart
path — is reached by any unit test, on any platform. The record and retirement
tests pin the helpers themselves, and the two
managed_engine_startup_failure_detail_*tests pin a renderer this PR only un-gates for Windows. The launch-path wiring
rests on serve-23; the restart-path wiring is not exercised end to end (see
Scenario coverage).
A spawn that never starts wedges the same record
Review also caught that this PR's own wedge fix had a hole beside it: if the
spawn fails outright (the exe cannot be launched at all), the
?returnedstraight past the retirement, stranding the pre-spawn record with its
"starting"status and pid 0 — the exact corpse the early-exit path was fixedto avoid.
The second review round found the hole was wider: six further fallible steps
sit between
record.write()and the spawn (create_dir_all,File::create,managed_service_launcher_path,builtin_engine_serve_http_args,env_root_for_service,ensure_public_service_has_endpoint_key), each strandingthe identical record. Every one now goes through
retire_record_on_error, whichreplaces
spawn_or_retire_recordand takes an already-evaluatedResultso itcan wrap the whole straight-line prefix without holding a second borrow of the
record.
It drops the retirement error rather than propagating it, and
fail_managed_launch_if_engine_diednow does the same — previously it used?there, so a failed record write would have replaced the engine's exit status and
log tail with a bookkeeping error, which is the opposite of what diagnoses the
launch.
The
mark_managed_launch_faileddoc comment is also scoped to the launch path,since the restart callers hold a pre-existing record whose pid is merely stale
rather than 0 — the behaviour was right at both, only the narrative was
launch-specific.
Scenario coverage
Per
AGENTS.md§3 this alters what a user can observe — the exit code and errortext of
rocm serve— so it carries a scenario rather than only unit tests.@id:serve-managed-engine-dies-at-startup(serve-23; it was serve-22 until mainadded its own serve-22 and this one was renumbered on rebase) in
tests/e2e-cucumber/features/model_serving.feature: the engine dies duringstartup, the serve fails naming the engine's own log, and the failed launch does
not block the next attempt. It carries no host tag, so it runs on every
lane — including Strix Halo Windows, which is the only place the Windows half of
the startup check executes at all, since no unit test can reach those call sites
from Linux.
An earlier revision of this PR tagged it
@requires-no-gpuand claimed on thatbasis that it covered Windows. That was wrong:
@requires-no-gpuis skipped onany host that has a GPU, so the scenario ran only on the Linux mock lane and
the Windows call sites stayed unexercised. The tag was also wrong on its own
terms — the premise is a dead engine, not an absent GPU. The test hook's waivers
are what make the premise hold on any host, so no host tag is needed.
It pins the wiring, verified by mutation: deleting the Unix launch call site
turns the scenario red, where the same deletion leaves all 765 binary tests green.
Its final assertion reads the
log_pathoff the service record the failed launchleaves behind and requires the output to name that exact path, rather than
matching a
.logsuffix. Checked by mutation: a build that names some other.logfile passed the old suffix check and fails this one.Not covered end to end: the restart path.
restart_internal_managed_servicegets the same startup check, but the scripted-death switch exists only on the
launch path, and restart is reachable only through the assistant's
restart_serversandbox tool. Covering it would mean adding another consumer ofe2e-test-hookswhile #349 is working to retire that feature, so it is statedhere as a gap instead. Restart shares
retire_record_on_errorandfail_managed_launch_if_engine_diedwith the launch path; only its two callsites are unexercised.
How the death is scripted, since the child is detached and cannot simply be told
to fail: an
e2e-test-hooksswitch read in the parent swaps the child'sarguments for ones the CLI rejects outright. The child is the real
rocmbinary,really spawned and really dead — only the trigger is scripted, so the launch takes
the same path a genuinely broken engine takes. Reading the switch in the parent
keeps all test-only behaviour out of the child. It is an unrecognised flag and
not an unrecognised subcommand on purpose: the CLI parses unknown subcommands as
natural-language requests and exits 0, which is a slower and different thing from
an engine dying.
Like the existing Lemonade seam it waives the no-GPU pre-flight, and it waives
engine preparation too, so the scenario reaches a real spawn on a lane with
neither GPU hardware nor an installed runtime. Both waivers are behind
e2e-test-hooks, which release builds do not enable.Confirmed executing on Windows, read from the lane's own log rather than
inferred from the lane's colour: at
ef67aa7d,E2E tests (Strix Halo, Windows)ran the scenario (then serve-22) and passed all four steps, as did WSL2; and again
at
72b0b5e3, after the rebase, as serve-23.One asymmetry worth knowing: the scripted path replaces the whole argument
vector, so the child never receives
--log. On Unix the parent redirects thechild's stdio into the service log, so the failure exercises the log-tail
branch of the detail renderer; on Windows the detached spawn does not redirect,
so the log is empty and the "no tail, still name the path" branch runs. The
scenario's assertion — that the exact service log path is named — holds on both
branches, but the two platforms do not prove the same branch. Asserting the tail on Windows would mean making the detached spawn
redirect stdio, which is a behaviour change out of scope here.
serve-18 (
@id:serve-lemonade-preparation-recovery) remains unrelated to thispath: it injects inside the parent's Install RPC and aborts before any spawn.
#351 is likewise independent — this PR neither depends on it nor touches it.
Searched
tests/e2e-cucumber/expectations.toml: no xfail row relates to thisbehaviour — the existing
serve-*rows are Linux/Lemonade shutdown issues.Verified vs not verified
Run locally on Linux, all clean:
cargo check --workspace --all-targets, andcargo check -p e2e-cucumber --test e2eseparately (that target setstest = false)cargo clippy --locked --workspace --all-targets -- -D warningsandcargo clippy --locked -p e2e-cucumber --test e2e -- -D warnings, with both sources touched first so nothing came from the clippy cachecargo fmt --all --checkcargo test -p rocm -p rocm-core --all-targets, plus the rest of the workspacecargo xtask manifest --check,cargo xtask check-crate-edges,python3 scripts/smoke_local.pyNot verified locally — there is no Windows host here: the runtime behaviour
of the new Win32 path. The two
#[cfg(windows)]tests have never executed; therequired
windows-build-and-testlane (which runscargo test --workspace --all-targets) is their first real run and is the verification for this change.Cross-compiling the crate outright is blocked on this machine —
ring's buildscript needs an MSVC C toolchain — so two narrower checks were used instead, and
neither is a substitute for the Windows lane:
DetachedSpawn, the modifiedspawn_windows_no_inherit,observe_early_exit,wait_timeout_millis, the three wrappers and both newtests) was extracted verbatim into a scratch crate and type-checked against
windows-sys0.61 forx86_64-pc-windows-msvc. The same was done for the#[cfg(windows)]arms rewritten in the review round —managed_startup_exit,and the
spawn_or_retire_recordclosures (since replaced byretire_record_on_error) with theirrecordborrows — so those were type- andborrow-checked for
x86_64-pc-windows-msvc, not merely reasoned about. Thecurrent
retire_record_on_errorarms were not re-checked that way; therequired
windows-build-and-testlane compiled and ran them at72b0b5e3, andthat is their verification. (Confirmed live by injecting a deliberate
i32/u32mismatch andwatching it fail.)
apps/rocmwas compiled with--cfg windowsforced on, purely to check thatthe
cfgarms resolve. That yields 13 errors against 9 on unmodifiedmain,and the four new ones are exactly
std::os::windows,ExitStatus::from_rawand the
rocm-corehelper being absent from a Linux build — no unresolvednames of our own.
No new dependency:
windows-sysalready enablesWin32_FoundationandWin32_System_Threading, which is all this needs.Related
start_managed_serviceandrestart_internal_managed_service,but addresses the readiness wait — an engine-supplied budget and an early exit
once the engine records a terminal state — rather than the spawn-time liveness
check, and adds no Windows check. Complementary; whichever lands second needs a
small rebase.
tests/e2e-cucumber/expectations.tomlfor stale xfail rows — none relate to this behaviour.