fix(e2e): serve loopback install downloads with a real HTTP server - #174
fix(e2e): serve loopback install downloads with a real HTTP server#174rominf wants to merge 5 commits into
Conversation
The install lifecycle's loopback HTTP server made its listener non-blocking so the accept loop could poll a stop flag. On Windows, accept() returns a socket that inherits the listening socket's non-blocking mode, so every served connection was non-blocking too. Whenever the request bytes had not already landed in the receive buffer, the first read() returned WouldBlock, the handler bailed out with no response, and the socket was reset — which the client reports only as a transport error, with no HTTP status to go on. That made the outcome depend on when the request arrived rather than whether it arrived, which is why the second of the installer's two back-to-back downloads (the archive, then its .sha256) was usually the one to break, and why re-running made it pass. Force accepted sockets into blocking mode with explicit read/write timeouts, and treat WouldBlock in the request-head reader as "try again" up to a deadline rather than as a failure. Move the server into the library crate so it is unit-testable, and cover both paths, including a real loopback socket deliberately left non-blocking to emulate the Windows accept() behaviour. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The loopback download server the Windows install scenario points the real installer at was hand-rolled on a raw TcpListener, so it also owned HTTP/1.x request framing, socket-mode handling and path-traversal defence. All three produced real defects: a one-shot read misparsed a request split across TCP segments, and an accepted socket on Windows inherits the listener's non-blocking mode, so a read failed outright whenever the request bytes had not landed yet -- the timing-dependent flake. Replace it with axum + tower-http's ServeDir, the stack the sibling mock server already uses, which owns all three concerns. The server runs on its own runtime thread so it keeps answering while the step blocks on the installer subprocess. tower-http 0.6 is the version reqwest already pulls in, so the dependency tree gains only http-range-header. The parser unit tests go away with the parser; in their place the tests drive the real server over real loopback sockets, covering the sequence that used to break (back-to-back downloads) and traversal. They are library unit tests, so `cargo test --workspace` runs them natively on Windows CI, where the fault actually lived. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The mock inference server and the install download server now both run an axum Router on an ephemeral loopback port and shut down on drop, so each was carrying its own copy of the bind/serve/graceful-shutdown lifecycle. Move that into one `http_server::ServerHandle`, with two entry points: spawn on the caller's runtime (the mock server, started from async code that yields normally) and spawn on a dedicated thread (the download server, whose caller then blocks on the installer subprocess). Each server is left with only the part that differs: its routes. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Assembling URLs with format! puts separator and escaping correctness on each
call site: the mock endpoint hard-coded a `/v1` suffix, the download tests
concatenated `{base}/{file}`, and a planted service record spelled out
`http://127.0.0.1:{port}/v1` by hand.
Give ServerHandle a `Url` built through url's own setters, and derive
everything else from it with `join`. `base_url()` stays a String for the
installer, which appends its own `/<file>` to ROCM_CLI_DOWNLOAD_BASE — unit
tests now pin that it has no trailing slash, and that a join produces exactly
one separator.
url is already in the dependency tree via reqwest, so this adds no crates.
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Pulled in by tower-http's ServeDir. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
volen-silo
left a comment
There was a problem hiding this comment.
Reviewed the full diff plus the branch history. No blocking defects — the root cause is correctly diagnosed and genuinely eliminated by construction, and the dependency/manifest/licence bookkeeping is right. Three items below are all about claims that are stronger than what the code actually establishes, which matters more than usual in a public repo.
Verified locally in a clean worktree at 718c2c1: cargo fmt --all --check, cargo test -p e2e-cucumber --lib (56 passed, including the 4 new loopback_http and 3 new http_server tests), cargo clippy -p e2e-cucumber --all-targets -- -D warnings, cargo xtask manifest --check — all pass. cargo xtask tpn --check could not run here (cargo-about not installed); the http-range-header 0.4.2 notice was checked by inspection instead and matches the vendored crate.
1. The traversal test cannot fail — Url::join strips the .. before the request is sent
tests/e2e-cucumber/src/loopback_http.rs:133-147
get() builds the request as server.url().join(path) (:64-73). Per RFC 3986 dot-segment removal, joining "../secret.txt" onto a base whose path is / yields /secret.txt — the traversal never leaves the process. The server only ever receives GET /secret.txt, which 404s because that file isn't in the served directory. The test is therefore equivalent in effect to missing_file_is_a_404 at :132-138, and would pass identically against a server with zero traversal defence.
The assertion compounds it: assert_ne!(body, "secret") never checks the status, so an empty 404 body passes.
To be clear, ServeDir's defence is real — un-normalised raw targets (/../secret.txt, /..%2Fsecret.txt, /%2e%2e/secret.txt, /..\secret.txt, /served/../secret.txt) all 404 against a live axum + ServeDir server. The property holds; the test just can't observe it. Either send the request over a raw socket with an un-normalised target and assert NOT_FOUND, or drop the test and rely on tower-http's own suite.
2. serves_back_to_back_downloads is labelled a regression test but wouldn't have caught the regression
tests/e2e-cucumber/src/loopback_http.rs:99-122, comment at :101-105
Rebuilding the pre-fix hand-rolled server and running this exact 4-request sequence against it: 50/50 pass on Linux naturally (Linux accept() doesn't inherit O_NONBLOCK, so the precondition never occurs), and only ~0.7% failures (4/600) even with the accepted socket forced non-blocking to emulate Windows. The test injects no timing pressure, so on Windows it would have passed the overwhelming majority of runs — the same way the original flake did.
Worth noting that 6c5e4be had exactly the right test for this — serves_back_to_back_requests_on_a_non_blocking_accepted_socket, which called set_nonblocking(true) on the accepted socket to force the condition rather than hope for it, plus retries_past_would_block_instead_of_failing_the_request and gives_up_on_a_socket_that_never_becomes_ready. 7ebf428 removed all three (grep -rnE 'WouldBlock|set_nonblocking' tests/e2e-cucumber/ returns nothing at HEAD).
The stronger argument is the one the PR already has: async I/O never surfaces WouldBlock to application code, so the bug class is eliminated by construction — a better guarantee than any test. Suggest leaning on that and softening the comment to describe what the test actually is (a behavioural check of the download sequence).
3. The module doc asserts a path-traversal defect that never existed
tests/e2e-cucumber/src/loopback_http.rs:11-18 — "Each of those was a real defect here."
Framing and socket-mode were real defects (fixed in ad12ac73 and 6c5e4be). Traversal wasn't: the removed safe_join (root.join → canonicalize() both sides → starts_with) is byte-identical from d17fc0c through 6c5e4be — it was never touched for a bug fix. Same overstatement in 7ebf428's commit message.
Related tradeoff worth a line while you're in there: ServeDir rejects .., drive prefixes and root components but never canonicalises, so unlike safe_join it follows symlinks out of the served root (confirmed: a symlink inside the root returns the outside file with 200). Irrelevant in practice — the root is a test-created TempDir with no attacker-controlled symlinks — but it means ServeDir isn't strictly superior on the axis the doc singles out.
Checked and clean
- Root cause. Confirmed against the removed code: listener set non-blocking, accepted stream's mode never cleared,
read_request_head'sreader.read(...)?propagatedWouldBlockasErr, andserve'slet _ = handle_conn(...)dropped the socket without writing a response. The fix removes the mechanism rather than moving the race. Uncredited bonus: the old loop ranhandle_conninline, so a slow client blocked every subsequentaccept()— also fixed. - Shutdown. The old
MockServerhad noDropimpl, but theoneshot::Senderfield's drop glue callscomplete(), waking the receiver exactly as an explicitsend(())does — "it still shuts down on drop" holds. - No nested-runtime hazard. The harness is
#[tokio::main]multi-thread;spawn_on_own_thread'snew_current_threadruntime is independent, so blocking a caller worker onaddr_rx.recv()can't deadlock. - URL changes are byte-identical.
join("v1")matches the oldformat!, andbase_url()still has no trailing slash. TracedROCM_CLI_DOWNLOAD_BASEthroughinstall.sh:339andinstall.ps1:599-600— no separator change. The installers issue plain GETs with noRange/If-None-Match/HEAD, soServeDir's extra capabilities are never exercised. - Dependencies.
Cargo.lockadds exactly one package (http-range-header 0.4.2);tower-http 0.6.11andurl 2.5.8were already in the tree at those versions and already inMANIFEST.md. Alphabetical placement correct, licence MIT confirmed from the vendored crate, dev-dep doesn't leak into shipped binaries.
Minor, take or leave: lifecycle_steps.rs:1222-1228's pub mod http shim has a single consumer (:770) and could be a direct use; LoopbackServer::url() (loopback_http.rs:54-57) is only called from that file's own #[cfg(test)] module.
Couldn't verify: Windows behaviour directly (no Windows host — the accept() inheritance mechanism is reasoned from the removed code plus a forced-condition emulation on Linux), and the ten green Windows E2E runs.
Fixes #173.
tests/e2e-cucumber/expectations.tomlfor the fixed ticket ID and removed/narrowed any now-stale xfail rows. (No rows referenced this scenario.)Symptom
lifecycle-windows-http-installfailed intermittently on the Windows job: theinstaller downloaded the archive from the test's local HTTP server, then failed
on the immediately following
.sha256request to the same server withAn error occurred while sending the request— a bare transport error, with no HTTPstatus. Re-running passed. It hit several unrelated PRs and reproduces on
main.Root cause
The server the scenario points the installer at was hand-rolled on a raw
TcpListener, so it also owned HTTP framing, socket-mode handling andpath-traversal defence. Two of those were wrong:
Windows,
accept()returns a socket that inherits the listener's non-blockingmode (Linux does not, which is why this was Windows-only), so if the request
bytes had not already landed, the first
read()returnedWouldBlock; thehandler propagated that as an error, wrote no response, and dropped the
socket, resetting the connection.
read(), so a request split acrossTCP segments parsed as
GET /and 404'd a legitimate download.Both make service depend on when a request arrives rather than whether it
arrives — matching the observed pattern, where the first request has process
setup ahead of it and the
.sha256request, issued the instant the archivedownload completes, is far more likely to lose the race.
Change
Rather than patch the hand-rolled server, replace it with
tower-http'sServeDironaxum— the stack the sibling mock server already uses, and onethat owns all three concerns. The server runs on its own runtime thread, since
the scenario step that starts it then blocks on the installer subprocess.
Two cleanups the rewrite made available:
http_server::ServerHandlefor thebind/serve/graceful-shutdown lifecycle, with two entry points (spawn on the
caller's runtime, or on a dedicated thread). Each server is left with only
its routes.
url::Urlwithjoininstead offormat!,so separators and escaping are not each call site's problem.
base_url()stays a
Stringfor the installer, which appends its own/<file>toROCM_CLI_DOWNLOAD_BASE; a unit test pins that it has no trailing slash.tower-http0.6 is the versionreqwestalready pulls in andurlis alreadyin the tree, so the only new crate is
http-range-header.Verification
The hand-rolled parser's unit tests are gone with the parser. In their place the
tests drive the real server over real loopback sockets — back-to-back downloads
(the sequence that used to break), a byte-for-byte 256 KiB body, 404, and
traversal. These are library unit tests, so
cargo test --workspaceruns themnatively on the Windows job, where the fault lived.
Ten consecutive green Windows E2E runs of all 13
Windows -install-lifecyclescenarios on the self-hosted Strix Halo runner: five against the first fix, five
against this rewrite. Given the flake rate, that is the evidence a single green
run cannot provide.
Locally on Linux:
cargo test -p e2e-cucumber --lib(56 passed),cargo clippy -p e2e-cucumber --all-targets -- -D warnings,cargo fmt --all --check,cargo xtask tpn --check,cargo xtask manifest --check.Risk
Low, and confined to test infrastructure — no shipped code changes. The blast
radius beyond the download server is the mock inference server, which every
scenario uses: its public API is unchanged and its lifecycle semantics are
preserved (it still shuts down on drop), with the full E2E suite covering it.