Skip to content

fix(http): return an error when the HTTP client cannot be built - #2273

Open
jbmusso wants to merge 2 commits into
voidzero-dev:mainfrom
jbmusso:fix/http-client-empty-trust-store
Open

fix(http): return an error when the HTTP client cannot be built#2273
jbmusso wants to merge 2 commits into
voidzero-dev:mainfrom
jbmusso:fix/http-client-empty-trust-store

Conversation

@jbmusso

@jbmusso jbmusso commented Jul 29, 2026

Copy link
Copy Markdown

Problem

This surfaced while upgrading vite-plus from 0.1.21 to 0.2.6 in a CI pipeline running on node:24-bookworm-slim: the lint and format jobs started aborting with exit 134, having been green on 0.1.21 on the same image.

The branch is two commits, so the bug can be inspected before the fix: check out the first commit and run cargo test -p vite_shared --lib client_build_failure to see the failure as a failing test, on an otherwise unmodified tree. The second commit is an attempt at a fix.

shared_http_client panics whenever the client cannot be built, and every input funnels into that one panic!: no system CA bundle, a malformed proxy setting, an unusable TLS backend, an unparsable extra CA bundle. Its doc comment states that a panic was chosen over process::exit so that:

/// destructors of in-flight work still run (lockfiles released, tempfiles
/// cleaned) and an embedding Node host (NAPI) keeps the process alive.

Cargo.toml has, under [profile.release]:

panic = "abort" # Let it crash and force ourselves to write safe Rust.

abort does not unwind, so in every release build the panic delivers neither property it was chosen for: lockfiles and temp dirs are not cleaned, and SIGABRT takes the embedding Node host down with it. The comment and the release profile have drifted apart, and every one of those inputs becomes an unrecoverable abort, worst in exactly the NAPI-embedded case the comment was written to protect.

The input that is near-universal in Debian-slim Node images is the missing CA bundle: they ship no ca-certificates package, and Node carries its own root list, so node and npm keep working and nothing else reveals it. On such an image any direct vp subcommand aborts instead of running:

Vite+ panicked. This is a bug in Vite+, not your code.

thread '<unnamed>' panicked at crates/vite_shared/src/http.rs:58:21:
failed to initialize HTTP client: builder error: unexpected error: No CA certificates were loaded from the system

Aborted (core dumped)          # exit 134

Reproduces in about 30s with only Docker:

docker run --rm -w /app node:24-bookworm-slim sh -c '
echo "{\"name\":\"repro\",\"private\":true}" > package.json
npm i --silent vite-plus@0.2.6
echo "{\"name\":\"repro\",\"private\":true,\"packageManager\":\"pnpm@10.27.0\"}" > package.json
./node_modules/.bin/vp fmt --check
echo "exit=$?"'

Holding image and project constant and varying only the vp version, 0.1.22 exits 1 with a handled error and 0.1.23 is the first release that aborts, the release that shipped the shared client from #1686. The empty trust store itself is not new (it has mattered since #1068 moved verification onto the OS store to fix #1014); what changed is that it became fatal.

Mechanism: execute_direct_subcommand calls envs_with_explicit_package_manager_path for every direct subcommand. With a packageManager field and a cold cache that downloads the pinned package manager, which builds the shared client. Nothing here actually needs the network, since the download failure is already handled and falls back to the unmodified PATH, so with any single root certificate present the same command completes normally even under --network none. Without a packageManager field there is no download attempt and no crash, which is why this only shows up on projects that pin one.

Solution

shared_http_client now returns Result<&Client, HttpClientError>, and the six call sites ? it into the errors they already return. That restores the behaviour the doc comment describes rather than adding a new policy: callers already treat "no HTTP client" as a handled outcome, they just never got the chance to. Every failure at that point is an environment condition (no system CA bundle, an unusable proxy, an unparsable extra CA bundle), so the message names the cause and the remedy instead of asking for a bug report, which currently points away from the fix.

The two artifacts cover different things, deliberately. The Docker snippet above reproduces the reported symptom; the unit test pins the branch. I could not reproduce the empty trust store portably in a unit test, because macOS resolves roots through Security.framework and SSL_CERT_FILE cannot empty the store there, so the test drives the same build() failure branch through the documented SSL_CERT_FILE contract, using a PEM block whose body is not valid DER. To check the real condition I built the patched vite_shared in a Debian container and deleted its CA store: it now returns the error and exits 0 instead of aborting.

I only looked at this one call site, where the doc comment states the intent explicitly, so I can't say whether the same drift between a documented panic rationale and panic = "abort" exists anywhere else. You may want to check.

Note

The investigation and this patch were done with AI assistance, and my own Rust is limited, so please scrutinise the implementation closely. The diagnosis is empirically verified (bisected across releases, reproduced in a container), but I have lower confidence in the patch being the shape you would want. Happy to rework it however you prefer, or to leave the diagnosis with you if you would rather fix it yourselves.

`shared_http_client` panics when `reqwest::ClientBuilder::build` fails.
Release builds set `panic = "abort"`, so that is not an unwind a caller
can recover from: the process dies with SIGABRT and destructors never
run. Callers already treat "no HTTP client" as a handled outcome, so the
failure has to reach them as a value.

The trigger seen in the wild is an image with no CA bundle, where the
platform verifier reports "No CA certificates were loaded from the
system". That is not reproducible portably, so the test drives the same
failure branch through the documented `SSL_CERT_FILE` contract with a
PEM block whose body is not valid DER.

This test fails on the current implementation.
@netlify

netlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploy Preview for viteplus-preview canceled.

Name Link
🔨 Latest commit 58c848c
🔍 Latest deploy log https://app.netlify.com/projects/viteplus-preview/deploys/6a6a7517146fb100085302d7

`shared_http_client` panicked when `reqwest::ClientBuilder::build` failed.
Because `[profile.release]` sets `panic = "abort"`, release builds turn
that into SIGABRT: the process is gone before any caller can react, and
destructors (download temp-dir guards, lock files) never run, and that was the very
thing the panic was documented to preserve.

Every failure at this point is an environment condition, not a vp bug:
no system CA bundle, an unusable proxy, an unparsable extra CA bundle.
Return `HttpClientError` instead and let the six call sites `?` it into
the errors they already return, so each caller decides. The message
names the cause and the remedy rather than asking for a bug report.

Most visible in container images with no `ca-certificates` package, where
the platform trust store loads zero roots. Node keeps its own root list
compiled in, so `node` and `npm` still work there and nothing else
reveals the missing bundle. Any direct subcommand (`fmt`, `lint`,
`check`) reaches this path when the project pins a `packageManager` that
is not in the managed cache yet, even though the download failure is
already handled and the command would otherwise finish fine.
@jbmusso
jbmusso force-pushed the fix/http-client-empty-trust-store branch from 63141ff to 58c848c Compare July 29, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reqwest calls in vp fail when running behind a MITM proxy.

1 participant