Repository navigation
refactor: rocm and rocmd run engine requests through one stdio client - #935
Closed
jussielo-amd wants to merge 7 commits into
Closed
jussielo-amd wants to merge 7 commits into
jussielo-amd wants to merge 7 commits into
Conversation
…nd how they read its answer rocm and rocmd each carry their own client for the JSON-over-stdio request to an engine process, and the two have drifted: they build the child's command line differently, set different environment, treat a failing exit status differently, word their errors differently, and only rocm guards the request write against SIGPIPE. Nothing pinned any of it, so the first step toward one client is to pin what each does today. Both clients are driven against a stand-in engine child, a shell script that records its argv, environment and stdin and then answers (or crashes) as the test asks. The stand-in lives in rocm-engine-protocol behind a test-support feature so the two callers share one. Each caller's out-of-process half is split into a small function that takes the executable to run (engine_stdio_child_request in rocm, engine_request_via in rocmd), so a test can aim it at the stand-in; the bodies are unchanged and the public entry points call them with the same arguments as before. The tests assert the child's argv (rocm: stdio; rocmd: __engine-stdio <engine>), the single newline-terminated request line, the environment each caller sets (rocm: the app path variables, the engine environments root, the progress variable for installs; rocmd: the endpoint key file when it holds a usable key), what each does with a nonzero exit, an unparseable answer and an error envelope, and the SIGPIPE case under the default action in a re-executed child. Mutating rocm's write guard away turns its SIGPIPE test red. No behaviour change. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…ared by rocm and rocmd rocm and rocmd each carried their own client for the JSON-over-stdio request to an engine process, and the two had drifted. They now run the child through one implementation in rocm-engine-protocol (stdio_client.rs); each caller still chooses which program to run, and what it asks of the client is a small set of options: the app paths, the engine environments root, an endpoint key file, a progress request, and whether to forward the child's stderr as it arrives. rocm still answers built-in engines in its own process and resolves the engine binary; rocmd still re-executes its own executable as __engine-stdio. Where the two behaved differently, the client takes the behaviour that reports more or fails more safely: - A failing exit status is an error only when the child printed no answer; an answer on stdout is decoded even if the child then exited non-zero (rocm's rule; rocmd discarded the answer for the status). - A parse failure carries the child's stderr, and the decode errors use rocm's wording (rocmd's said "failed to parse engine response", "engine response did not include data" and "failed to deserialize engine response data"). - The write of the request is guarded against SIGPIPE (rocm's rule). rocmd had no guard, so the daemon hosted in rocm, which runs with SIGPIPE at its default action, was killed by a write to an engine that had already exited instead of reporting why. - The child runs on the caller's app paths in both (rocmd now sets the ROCM_CLI_*_DIR variables; it relied on inheriting them), and the spawn error names the program in both. - A request whose write fails kills and reaps the child, so the long-lived daemon does not collect a zombie per failure. - Forwarded stderr no longer panics if this process's stderr is closed. The endpoint key stays an option only rocmd uses, since none of rocm's requests is about a running service's endpoint; rocm still sets no key. Stderr is forwarded for rocm and only kept for rocmd. Neither client has, or gains, a timeout. Also moved to where both can reach them: with_sigpipe_ignored (and its re-exec test, same name) from rocm's main.rs to rocm-core; the three ROCM_CLI_*_DIR variables as AppPaths::env_vars, which rocm's app_path_env_vars and rocmd's capture wrapper now call instead of each listing them; and the byte-identical optional_arg, from both binaries into rocm-engine-protocol. The fake engine written for the tests now plants its script with rocm_core::fake_executable instead of retrying on ETXTBSY. Both callers' tests now run through the shared client and flip what the previous commit pinned (rocmd's app paths, nonzero-with-answer, SIGPIPE under the default action, error wording). Breaking either caller's options, the client's SIGPIPE guard or an environment variable turns a test red. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
… the standalone daemon rocmd asked an engine to do something by re-executing its own executable as `__engine-stdio <engine>`. That verb is served by rocm. Embedded as `rocm daemon` the daemon is rocm and it works, but the standalone rocmd binary shipped beside rocm answered every such request with "unrecognized subcommand '__engine-stdio'" and exit status 2. The request was made by the stop tool (`rocmd sandbox-tool stop_server`, `rocmd mcp-server`), by the supervisor's readiness wait and by the server-recover watcher's healthcheck, so from the standalone daemon the engine `Stop` always came back as an error and no healthcheck ever succeeded; for an engine whose stop decides confirmation, the stop could never be confirmed. When the running executable is exactly `rocmd`, the request now runs the `rocm` beside it (rocm_core::sibling_binary_path), and says so if there is none. Anything else, the embedded daemon, a test binary or a renamed copy, keeps using its own executable as before. The `service-stop-02` scenario drives the standalone rocmd and now also asserts that the engine `Stop` request was answered. Run against the previous commit's rocmd it fails with the engine_stop error above; with this change it passes, along with the rest of the service-stop scenarios. The choice of executable is a pure function with its own unit test that also runs on Windows. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
Signed-off-by: Jussi Elo <jussi.elo@amd.com>
jussielo-amd
marked this pull request as ready for review
November 5, 2026 21:24
jussielo-amd
requested review from
juhovainio
and
a balanced review from Copilot
November 5, 2026 21:24
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Progress forwarding can terminate the CLI via SIGPIPE on a closed stderr pipe, and unescaped apostrophes in responses or scratch paths break the fake engine’s generated shell script.
2 open issues
What changed in this PR
Consolidates rocm and rocmd engine requests into one stdio client while preserving caller-specific options and the existing wire format. Also fixes standalone rocmd engine requests by routing them through the sibling rocm executable.
Changes:
- Shares request framing, response decoding, subprocess handling, and common helpers.
- Preserves each caller’s environment, endpoint-key, and progress settings.
- Adds shared fake-engine tests, standalone-daemon regression coverage, and documentation.
Verification here used standalone signal and shell checks; project tests were not run.
| File | Description |
|---|---|
tests/e2e-cucumber/tests/e2e/service_stop_steps.rs |
Asserts that the engine answered the stop request. |
tests/e2e-cucumber/features/service_stop.feature |
Adds the engine-answer assertion to the stop scenario. |
docs/testing.md |
Documents client tests and standalone-daemon coverage. |
docs/architecture.md |
Updates shared-client and helper ownership. |
crates/rocm-engine-protocol/src/stdio_client.rs |
Implements the shared stdio client and tests. |
crates/rocm-engine-protocol/src/lib.rs |
Exports client APIs and the optional-argument helper. |
crates/rocm-engine-protocol/src/fake_engine.rs |
Adds the reusable fake engine fixture. |
crates/rocm-engine-protocol/Cargo.toml |
Adds client dependencies and test support. |
crates/rocm-core/src/sigpipe.rs |
Relocates the SIGPIPE guard and regression test. |
crates/rocm-core/src/paths.rs |
Centralizes child environment paths and the root constant. |
crates/rocm-core/src/lib.rs |
Exports the shared path and signal helpers. |
Cargo.lock |
Records dependency changes. |
apps/rocmd/src/supervise.rs |
Uses the shared optional-argument helper. |
apps/rocmd/src/engine_ipc.rs |
Adopts the client and fixes standalone executable selection. |
apps/rocmd/src/common.rs |
Reuses shared path environment mappings. |
apps/rocmd/Cargo.toml |
Enables fake-engine and signal test dependencies. |
apps/rocm/src/main.rs |
Removes the relocated SIGPIPE helper and test. |
apps/rocm/src/engine_runtime_select.rs |
Delegates path mappings and removes duplicated argument logic. |
apps/rocm/src/engine_ipc.rs |
Delegates subprocess requests and tests caller options. |
apps/rocm/Cargo.toml |
Enables shared fake-engine test support. |
Review effort: Balanced
| Ok(0) => break, | ||
| Ok(_) => { | ||
| if let Some(sink) = forward.as_mut() { | ||
| let _ = sink.write_all(&line); |
Comment on lines
+56
to
+69
| dir.display() | ||
| ), | ||
| ) | ||
| .expect("write the fake engine script"); | ||
| Self { dir, script } | ||
| } | ||
|
|
||
| /// A fake that reads its request and answers with `response` (a complete | ||
| /// response envelope as JSON text), exiting with `status`. | ||
| pub fn answering(label: &str, response: &str, status: i32) -> Self { | ||
| Self::new( | ||
| label, | ||
| true, | ||
| &format!("printf '%s' '{response}'\nexit {status}"), |
Resolves the conflicts with the clap-tree move: the tests it relocated to cli.rs and the SIGPIPE test this branch moved to rocm-core are both gone from main.rs, the engine_ipc.rs module comment keeps this branch's wording, and the docs/architecture.md engine_ipc.rs entry keeps this branch's text plus the note that the watcher-mode conversion now lives in cli.rs. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
`rocm` runs with SIGPIPE at its default action. When it forwards an engine child's progress to a stderr whose reader has gone (a closed pipe), the write delivers the signal and ends the CLI before `write_all` can return, so discarding the result did not protect the request. Both forwarding writes in `collect_stderr` (a line of progress, and the note recorded when reading the child's stderr fails) now go through one helper that runs the write inside `with_sigpipe_ignored`, which turns the signal into the error that is then discarded. `with_sigpipe_ignored` is now safe to overlap. It saved and restored the disposition per call, so two overlapping regions (the stderr forwarder of one request next to the stdin write of another in the daemon) could restore "ignored" over a default action, or the default under a region still open. It remembers the disposition in force before the first region and restores it when the last one ends, and restores on unwind. The re-executed test installs SIG_DFL in a fresh process and forwards into a real pipe whose reader is closed, once per write; the existing `Closed` mock returns the error itself and cannot raise the signal. A second re-executed test covers nested and interleaved regions. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
`FakeEngine` built its script by interpolating the canned response and the scratch directory into single-quoted shell literals. A valid JSON response with an apostrophe (`can't stop`) ended the literal early, so the fake exited with a syntax error instead of printing the envelope, and a `TMPDIR` containing an apostrophe broke the `dir=` line the same way. Both values now go through one `shell_quote` (close the literal, emit an escaped quote, reopen it), and `FakeEngine::new_in` lets a test choose the scratch base without changing the process-wide TMPDIR. Tests cover the quoting helper, the response and the scratch directory against the script itself, and the apostrophe response through the real client. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
rocmandrocmdeach carried their own client for the JSON-over-stdio request to an engine process, and the two had drifted apart. They now run the child through one implementation,stdio_client.rsinrocm-engine-protocol, and each caller keeps only what is genuinely its own: which program to start, and a small set of options for what it asks of the client. This delivers ROCMAI-875 (item 3 of the Phase 8.4-H follow-up work). A wire-format change is not part of it: the request and response envelopes are untouched, so the engines need no change.Writing the characterisation tests first also settled the open question about the standalone
rocmdbinary, and it turned out to be a live bug rather than a corner case.rocmdreached an engine by re-executing its own executable as__engine-stdio <engine>, a verb that onlyrocmserves. Embedded asrocm daemonthat works. The standalonerocmdshipped besiderocm(it is in the release packages and is what the e2e suite drives) answers every such request witherror: unrecognized subcommand '__engine-stdio'and exit status 2. So from the standalone daemon the engineStopofrocmd sandbox-tool stop_serveralways came back as an error, and every healthcheck failed; for an engine whose stop decides confirmation (aim) the stop could never be confirmed. The third commit fixes that.What moved
rocm'sengine_stdio_child_requestbody: spawn, write the request, collect stderr, read stdout, wait, parse (apps/rocm/src/engine_ipc.rs)run_engine_stdioincrates/rocm-engine-protocol/src/stdio_client.rs;rocmkeeps a thinengine_stdio_child_requestthat picks the optionsrocmd'sengine_requestbody (apps/rocmd/src/engine_ipc.rs)run_engine_stdio;rocmdkeepsengine_request/engine_request_viaand picks its optionsdecode_engine_response(rocm) and the inline decode (rocmd)decode_engine_responseinstdio_client.rsengine_request_envelopeinstdio_client.rswith_sigpipe_ignoredand its re-exec test,reset_sigpipe's sibling inapps/rocm/src/main.rsrocm_core::with_sigpipe_ignoredin the newcrates/rocm-core/src/sigpipe.rs, test with the same nameROCM_CLI_*_DIRpairs listed inrocm'sapp_path_env_varsand inrocmd'srun_rocm_capture_for_pathsAppPaths::env_varsinrocm-core; both call it. TheROCM_CLI_ENGINE_ENVS_ROOTname isrocm_core::ENGINE_ENVS_ROOT_ENVoptional_arginapps/rocmd/src/common.rsandapps/rocm/src/engine_runtime_select.rsrocm_engine_protocol::optional_arg, besidegpu_indices_to_csvFakeEngineincrates/rocm-engine-protocol/src/fake_engine.rsbehind atest-supportfeature, planted withrocm_core::fake_executableThe shared client lives in
rocm-engine-protocolrather thanrocm-corebecause it needs the envelope types androcm-engine-protocolalready depends onrocm-core;cargo xtask check-crate-edgesis unchanged and green. The crate gains a dependency onanyhow(already in the workspace) and thetest-supportfeature.Which behaviour wins, and every user-visible difference
Where the two copies differed, the client takes the behaviour that reports more or fails more safely.
rocmalready did this;rocmdthrew the answer away for the status. In practicerocm __engine-stdioalways exits 0, so this only matters for a real crash.failed to parse engine response envelope; stderr: ...), and the decode errors userocm's wording.rocmdusedfailed to parse engine response,engine response did not include dataandfailed to deserialize engine response data; those strings appear only in the daemon's own errors, and nothing in the repository matched on them.rocmhad the guard;rocmddid not. Rust starts a process with SIGPIPE ignored, so the standalonerocmdwas never exposed, butrocm daemonruns the daemon insiderocm, which resets SIGPIPE to its default action inmain. There, a write to an engine child that had already exited killed the whole daemon before it could say why. It is now an ordinary error.rocmdnow setsROCM_CLI_CONFIG_DIR,ROCM_CLI_DATA_DIRandROCM_CLI_CACHE_DIRon the engine child from its ownAppPaths, asrocmdid. Before, it relied on inheriting them. No change in production, where the two agree; it makes the child act on the roots the daemon was given.failed to spawn engine stdio process <path>);rocm's used to omit the path.rocmusedeprint!; the client ignores a failed forward and still collects the text).rocmd(third commit): when the running executable is exactlyrocmd, engine requests run therocmbeside it (rocm_core::sibling_binary_path), and the error says so if there is none. Embedded inrocm, a test binary or a renamed copy still use their own executable.What stays an option per caller, deliberately:
rocmforwards the child's stderr to the terminal (that is how an install's progress shows) and setsROCM_ENGINE_PROGRESS_STDERR=1for an install;rocmdonly keeps stderr for the error and asks for no progress.rocmdthreads the service's endpoint key file onto the child when it holds a usable key (the healthcheck's HTTP probe needs it);rocmpasses none, because none of its requests (detect, install, resolve-model, stop) is about a running service's endpoint, sorocmdoes not gain the key. The engine environments root isrocm-only. Neither client has, or gains, a timeout; an engine install legitimately runs for minutes, so adding one is a separate decision. The in-process answer for the built-in engines stays inrocm;rocmdcannot link the engines.Verification
Ran:
cargo fmt --all --check;cargo clippy -p rocm -p rocmd -p rocm-engine-protocol -p rocm-core -p e2e-cucumber -p xtask --all-targets -- -D warnings, and the same for the three engine crates and the dash crates;cargo test --no-fail-fastforrocm-core,rocm-engine-protocol,rocmd,rocm, the three engine crates,xtaskand the non-harnesse2e-cucumbertargets, all green apart fromcomfyui::tests::status_reports_stopped_when_saved_comfyui_pid_is_gone, which failed once under the full parallel run and passes on its own (it depends on a saved PID being free; it does not touch this code);cargo xtask manifest --check;cargo xtask check-crate-edges; the pre-commit hooks (hawkeye, lychee, fmt) on each commit; a leak scan oforigin/main...HEAD(hits are the wordsprivate/internalused in the technical sense). A leaf-name test diff against the base shows no test lost (the SIGPIPE re-exec test moved torocm-coreunder the same name).Each new test was shown red by breaking its target. Removing the SIGPIPE guard from the shared client turns three tests red (its own,
rocm's androcmd's, each a re-executed process killed by SIGPIPE); dropping the endpoint key fromrocmd's options, flippingrocm'sforward_stderr, or dropping the environments-root variable from the client each turns the tests for that option red; replacing the call inrocmdwith a private copy of its old client turns five of its tests red; makingrocm_executable_fromignore the standalone case turns its unit test red.The scenario that exercises the standalone daemon was run through the real harness with an
e2e-test-hooksdebug build (ROCM_CLI_BINARYandROCM_CLI_ROCMD_BINARYpointing at it):-n "service-stop"is 5 scenarios, all passing. The new step inservice-stop-02fails against the second commit'srocmdwith theunrecognized subcommand '__engine-stdio'engine_stop error and passes with the third.Not run:
cargo check --target x86_64-pc-windows-msvc. This host has no C toolchain for that target (ring's build script needslib.exe), so thecfg(windows)side could not be compiled. The code that differs on Windows is onlywith_sigpipe_ignored's pass-through and the#[cfg(unix)]fake-engine tests; everything the Windows job should run is a pure function or an injectable seam (engine_stdio_command,decode_engine_response, the stderr collector,stdio_options,rocm_executable_from), and the unit tests for those are not unix-gated. The full workspace gate,cargo xtask coverage,scripts/smoke_local.pyand the GPU lanes were not run; CI carries them.User-visible behaviour
@id:service-stop-confirms-a-stop-and-cleans-up(service-stop-02, Linux, mock lane) drives the standalonerocmd sandbox-tool stop_serverand now also asserts that the engineStoprequest was answered rather than turned into theengine_stoperror above; that is the user-visible effect of the third commit (the report'sengine_stopfield).@id:service-stop-yes-stops-a-running-service(service-stop-01) andservice-stop-03to-05runrocm services stop, which answers a built-in engine in process and never starts a child, and are unaffected; they pass. Install, serve and engines scenarios go through the samerocmcode and are unchanged apart from the spawn error now naming the program; the ones that start an engine child (GPU or lifecycle lanes) are exercised by the lanes that carry them. Everything else is internal.Notes for reviewers
Read the commits in order. The first only pins behaviour: it adds
FakeEngine, splits each caller's out-of-process half into a function that takes the executable (engine_stdio_child_request,engine_request_via) so a test can aim it at the fake, and adds tests that pass on the unmodified code, including the SIGPIPE case in a re-executed child and the divergences above. The second is the shared client; read its test changes as the diff of what the first commit pinned. The third is independent and can be dropped without affecting the second.Two things I found and did not change. In the embedded daemon,
restart_managed_servicere-launchescurrent_exe() supervise ..., butrocmhas nosuperviseverb (rocm superviseanswersunrecognized subcommand), so recovery restarts from the embedded daemon cannot start a supervisor; the mirror of the bug fixed here, and outside this change. Androcm'sresolve_engine_binary_pathlooks plugins up throughAppPaths::discover()rather than thepathsit is given.Open PRs touching the same files: the endpoint-key guard PR edits
apps/rocmd/src/engine_ipc.rs(deletesensure_public_service_has_endpoint_keyand a doc line; the hunks are apart from mine but the imports at the top of the file will need a look),rocm-core/src/lib.rsandrocm-engine-protocol/src/lib.rs; the managed-service stop PR touchesrocm-core/src/lib.rsandapps/rocmd/src/service.rs; the clap-tree move PR touchesapps/rocm/src/main.rsandengine_ipc.rs. The base moved while this was in review; I mergedorigin/main(the engine-inventory removal), resolving theengine_ipc.rsmodule comment and thedocs/architecture.mdentries, and the merge is signed.