diff --git a/.claude/reference/dependencies.md b/.claude/reference/dependencies.md
index d923303f9e..bd78ff120b 100644
--- a/.claude/reference/dependencies.md
+++ b/.claude/reference/dependencies.md
@@ -22,7 +22,8 @@
- For runtime-only dependencies, use dynamic loading so bundlers do not eagerly include them.
- Build the module specifier from string parts (for example with `["pkg", "name"].join("-")` or `["@scope", "pkg"].join("/")`) instead of a single string literal.
- Prefer this pattern for modules like `@rivetkit/rivetkit-napi/wrapper`, `sandboxed-node`, and `isolated-vm`.
-- The TypeScript registry's native envoy path dynamically loads `@rivetkit/rivetkit-napi` and `@rivetkit/engine-cli` so browser and serverless bundles do not eagerly pull native-only modules.
+- The TypeScript registry's native envoy path dynamically loads `@rivetkit/rivetkit-napi` so browser and serverless bundles do not eagerly pull native-only modules.
+- `rivetkit` does not depend on the Engine or Services binaries. `rivetkit-engine-process::download` fetches them on demand from releases.rivet.dev at exact pinned versions, verified against `SHA256SUMS`; bump `SERVICES_VERSION` in `rivetkit-core/src/services_process.rs` to move Services.
- If loading by resolved file path, resolve first and then import via `pathToFileURL(...).href`.
## Version bumps
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index f3f6b2ce99..1fc888f98f 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -279,6 +279,7 @@ jobs:
--build-arg BUILD_TARGET=${{ matrix.build_target }} \
--build-arg BUILD_MODE=${{ steps.mode.outputs.build_mode }} \
--build-arg BUILD_FRONTEND=${{ matrix.build_target == 'engine' && 'true' || 'false' }} \
+ --build-arg RIVET_ENGINE_RELEASE_REF=${{ needs.context.outputs.trigger != 'release' && contains(needs.context.outputs.targets, 'engine') && needs.context.outputs.sha || '' }} \
-f ${{ matrix.docker }} \
-t builder-${{ matrix.build_target }}-${{ matrix.platform }} \
--load \
@@ -596,19 +597,9 @@ jobs:
- name: Finalize package versions for publish
run: pnpm --filter=publish exec tsx src/ci/bin.ts bump-versions --version ${{ needs.context.outputs.version }}
- - name: Publish npm packages
- env:
- # setup-node provides a dummy token value; clear it so token-only
- # post-publish operations stay disabled for trusted publishing.
- NODE_AUTH_TOKEN: ""
- SKIP_WASM_BUILD: "1"
- run: |
- pnpm --filter=publish exec tsx src/ci/bin.ts publish-npm \
- --tag ${{ needs.context.outputs.npm_tag }} \
- --parallel 16 \
- --retries 3 \
- ${{ needs.context.outputs.trigger == 'release' && '--release-mode' || '' }}
-
+ # RivetKit downloads the Engine from releases.rivet.dev on demand, so the
+ # binaries must be published before npm. A failed upload stops the job
+ # before any npm package can point at a missing Engine.
- name: Generate engine checksum manifest
if: contains(needs.context.outputs.targets, 'engine')
run: |
@@ -635,6 +626,19 @@ jobs:
--version ${{ needs.context.outputs.version }} \
--latest ${{ needs.context.outputs.latest }}
+ - name: Publish npm packages
+ env:
+ # setup-node provides a dummy token value; clear it so token-only
+ # post-publish operations stay disabled for trusted publishing.
+ NODE_AUTH_TOKEN: ""
+ SKIP_WASM_BUILD: "1"
+ run: |
+ pnpm --filter=publish exec tsx src/ci/bin.ts publish-npm \
+ --tag ${{ needs.context.outputs.npm_tag }} \
+ --parallel 16 \
+ --retries 3 \
+ ${{ needs.context.outputs.trigger == 'release' && '--release-mode' || '' }}
+
# The Inspector UI lives in frontend/dist, outside the rivetkit-core
# crate, so it must be built and staged into the crate before packaging.
# Otherwise the published crate embeds the empty fallback and serves
diff --git a/Cargo.lock b/Cargo.lock
index 7d4e0fd88b..1bf99329b0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6422,6 +6422,7 @@ name = "rivetkit-engine-process"
version = "2.3.30"
dependencies = [
"anyhow",
+ "dirs",
"libc",
"reqwest 0.12.22",
"rivet-error",
diff --git a/docker/build/darwin-arm64.Dockerfile b/docker/build/darwin-arm64.Dockerfile
index a0f21dd63f..999b34b442 100644
--- a/docker/build/darwin-arm64.Dockerfile
+++ b/docker/build/darwin-arm64.Dockerfile
@@ -7,6 +7,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-osxcross:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/darwin-x64.Dockerfile b/docker/build/darwin-x64.Dockerfile
index 91b754438f..3edd24d5dd 100644
--- a/docker/build/darwin-x64.Dockerfile
+++ b/docker/build/darwin-x64.Dockerfile
@@ -7,6 +7,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-osxcross:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/linux-arm64-gnu.Dockerfile b/docker/build/linux-arm64-gnu.Dockerfile
index ac03643ae5..9479058855 100644
--- a/docker/build/linux-arm64-gnu.Dockerfile
+++ b/docker/build/linux-arm64-gnu.Dockerfile
@@ -7,6 +7,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-linux-gnu:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/linux-arm64-musl.Dockerfile b/docker/build/linux-arm64-musl.Dockerfile
index c036640658..f613d600ef 100644
--- a/docker/build/linux-arm64-musl.Dockerfile
+++ b/docker/build/linux-arm64-musl.Dockerfile
@@ -7,6 +7,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-linux-musl:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/linux-x64-gnu.Dockerfile b/docker/build/linux-x64-gnu.Dockerfile
index ea5da10c59..29f15f5cc8 100644
--- a/docker/build/linux-x64-gnu.Dockerfile
+++ b/docker/build/linux-x64-gnu.Dockerfile
@@ -14,6 +14,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-linux-gnu:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/linux-x64-musl.Dockerfile b/docker/build/linux-x64-musl.Dockerfile
index 3c1ce9f7c9..870c1a4ccd 100644
--- a/docker/build/linux-x64-musl.Dockerfile
+++ b/docker/build/linux-x64-musl.Dockerfile
@@ -7,6 +7,8 @@ FROM ghcr.io/rivet-dev/rivet/builder-base-linux-musl:0e33ceb98
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docker/build/windows-x64.Dockerfile b/docker/build/windows-x64.Dockerfile
index 82045b5235..911fa07dfd 100644
--- a/docker/build/windows-x64.Dockerfile
+++ b/docker/build/windows-x64.Dockerfile
@@ -17,6 +17,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends nasm && \
ARG BUILD_TARGET=engine
ARG BUILD_MODE=release
+# Publish commit whose Engine a preview rivetkit-napi build downloads. Empty for releases.
+ARG RIVET_ENGINE_RELEASE_REF=
ARG BUILD_FRONTEND=false
ARG VITE_APP_API_URL=__SAME__
ARG VITE_FEATURE_FLAGS=
diff --git a/docs/actors/content/docs/quickstart/rust.mdx b/docs/actors/content/docs/quickstart/rust.mdx
index bb776989d0..24277cbd4f 100644
--- a/docs/actors/content/docs/quickstart/rust.mdx
+++ b/docs/actors/content/docs/quickstart/rust.mdx
@@ -127,10 +127,10 @@ Replace `counter` with your crate name (the package `name` in `Cargo.toml`, with
-The Rust runtime connects to the Rivet control plane. Setting `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1` lets the runtime download and cache a matching engine binary the first time you run, so there is nothing else to install:
+The Rust runtime connects to the Rivet control plane. The first time you run, it downloads and caches a matching engine binary and the Rivet Services binary, so there is nothing else to install:
```sh
-RIVETKIT_ENGINE_AUTO_DOWNLOAD=1 cargo run
+cargo run
```
Your server now connects to the Rivet control plane on `http://localhost:6420`. Clients connect directly to the control plane on this port.
@@ -138,7 +138,7 @@ Your server now connects to the Rivet control plane on `http://localhost:6420`.
Visit [http://localhost:6420](http://localhost:6420) in your browser (or point your AI agent at it) to open the Rivet developer tools and inspect your actors live.
-Already have a control plane binary? Set `RIVET_ENGINE_BINARY_PATH=/path/to/rivet-engine` to point at it instead. If you are working inside the [Rivet monorepo](https://github.com/rivet-dev/rivet), a local `cargo build -p rivet-engine` is discovered automatically from `target/debug`.
+Already have a control plane binary? Set `RIVET_ENGINE_BINARY_PATH=/path/to/rivet-engine` to point at it instead. Set `RIVET_RUN_SERVICES=0` to run without Services, or `RIVET_DISABLE_BINARY_DOWNLOAD=1` to turn off downloads. If you are working inside the [Rivet monorepo](https://github.com/rivet-dev/rivet), a local `cargo build -p rivet-engine` is discovered automatically from `target/debug`.
diff --git a/docs/api/error-registry.json b/docs/api/error-registry.json
index 632e5b5a67..2d2a0f8156 100644
--- a/docs/api/error-registry.json
+++ b/docs/api/error-registry.json
@@ -579,16 +579,6 @@
"message": "Engine binary is unavailable.",
"statuses": []
},
- {
- "code": "checksum_mismatch",
- "message": "Engine binary checksum mismatch.",
- "statuses": []
- },
- {
- "code": "download_failed",
- "message": "Engine binary download failed.",
- "statuses": []
- },
{
"code": "health_check_failed",
"message": "Engine health check failed.",
diff --git a/docs/general/content/cli.mdx b/docs/general/content/cli.mdx
index b82087d57c..e3366edc1c 100644
--- a/docs/general/content/cli.mdx
+++ b/docs/general/content/cli.mdx
@@ -153,7 +153,7 @@ By default the namespace must already exist. Pass `--create-namespace` to create
## Engine binary resolution
-`rivet dev` and `rivet engine` resolve the `rivet-engine` binary from, in order: `--engine-binary`, `RIVET_ENGINE_BINARY_PATH`, a binary bundled next to the CLI, a local `target/{debug,release}` build, then an auto-downloaded release. Set `RIVETKIT_ENGINE_AUTO_DOWNLOAD=0` to require a local binary.
+`rivet dev` and `rivet engine` resolve the `rivet-engine` binary from, in order: `--engine-binary`, `RIVET_ENGINE_BINARY_PATH`, a binary bundled next to the CLI, a local `target/{debug,release}` build, then an auto-downloaded release. The downloaded release matches the CLI version unless `RIVET_RUN_ENGINE_VERSION` names an exact version. Set `RIVET_DISABLE_BINARY_DOWNLOAD=1` to require a local binary.
## Related
diff --git a/docs/general/content/environment-variables.mdx b/docs/general/content/environment-variables.mdx
index bbf5810f94..936e6e560f 100644
--- a/docs/general/content/environment-variables.mdx
+++ b/docs/general/content/environment-variables.mdx
@@ -40,8 +40,16 @@ These variables configure how clients connect to your actors.
| Environment Variable | Description |
|---------------------|-------------|
| `RIVET_RUN_ENGINE` | Set to `1` to spawn the control plane process |
-| `RIVET_RUN_ENGINE_HOST` | Host to bind the spawned local engine process to. Defaults to `127.0.0.1`. |
-| `RIVET_RUN_ENGINE_PORT` | Port to bind the spawned local engine process to. Defaults to `6420`. |
-| `RIVET_RUN_ENGINE_VERSION` | Version of engine to download |
-| `RIVET_RUN_SERVICES` | Set to `1` to run Services with a local Engine, or `0` to disable it. By default, Services follows RivetKit's local-Engine decision. |
-| `RIVET_SERVICES_BINARY` | Path to a local `rivet-services` binary. |
+| `RIVET_RUN_ENGINE_HOST` | Host to bind the spawned local engine process to. Defaults to the host of the local endpoint (`127.0.0.1`). |
+| `RIVET_RUN_ENGINE_PORT` | Port to bind the spawned local engine process to. Defaults to the port of the local endpoint (`6420`). |
+| `RIVET_RUN_ENGINE_VERSION` | Exact Engine version to download for local development, such as `2.3.30`. Defaults to the installed RivetKit version. Aliases such as `latest` are rejected. `RIVETKIT_ENGINE_VERSION` is a deprecated alias. |
+| `RIVET_RUN_SERVICES` | Set to `1` to run Services with a local Engine, or `0` to disable it. Services runs by default whenever RivetKit runs a local Engine. Without an explicit `1`, platforms without a Services build (such as Alpine) run without it after a warning. |
+| `RIVET_ENGINE_BINARY_PATH` | Path to a local `rivet-engine` binary. Skips the download. |
+| `RIVET_SERVICES_BINARY` | Path to a local `rivet-services` binary. Skips the download. |
+| `RIVET_DISABLE_BINARY_DOWNLOAD` | Set to `1` to never download the Engine or Services binaries, for offline or locked-down environments. Provide the binaries with the variables above instead. Overrides every other download setting. |
+| `RIVETKIT_ENGINE_AUTO_DOWNLOAD` | Set to `0` to stop downloading binaries that are not already cached. Defaults to `1`. |
+| `RIVET_BINARY_CACHE_DIR` | Directory for downloaded binaries. Defaults to the user cache directory (`$XDG_CACHE_HOME/rivet` or `~/.cache/rivet` on Linux, `~/Library/Caches/rivet` on macOS, `%LOCALAPPDATA%\rivet` on Windows). |
+
+When RivetKit runs a local Engine for development, it downloads the Engine that matches your RivetKit version and the Services release pinned by it from `releases.rivet.dev` on first use, verifies their SHA-256 checksums, and caches them for later runs. Nothing is downloaded when `RIVET_ENDPOINT` points at a remote Engine.
+
+TypeScript and Rust apps resolve these variables the same way. A value set in code (such as `engineVersion`, `startServices`, `engineHost`, or `enginePort` in the TypeScript registry config) takes precedence over its environment variable, which takes precedence over the default.
diff --git a/docs/integrations/content/docs/vercel-eve.mdx b/docs/integrations/content/docs/vercel-eve.mdx
index 8be2ba1031..04de26c13a 100644
--- a/docs/integrations/content/docs/vercel-eve.mdx
+++ b/docs/integrations/content/docs/vercel-eve.mdx
@@ -64,7 +64,6 @@ export default defineAgent({
"@rivet-dev/agentos-runtime-core",
"@rivet-dev/agentos-sidecar",
"@rivet-dev/workflow-world",
- "@rivetkit/engine-cli",
],
},
experimental: {
diff --git a/engine/artifacts/errors/engine.checksum_mismatch.json b/engine/artifacts/errors/engine.checksum_mismatch.json
deleted file mode 100644
index 089c1ef13f..0000000000
--- a/engine/artifacts/errors/engine.checksum_mismatch.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "code": "checksum_mismatch",
- "group": "engine",
- "message": "Engine binary checksum mismatch."
-}
\ No newline at end of file
diff --git a/engine/artifacts/errors/engine.download_failed.json b/engine/artifacts/errors/engine.download_failed.json
deleted file mode 100644
index e10e382f46..0000000000
--- a/engine/artifacts/errors/engine.download_failed.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "code": "download_failed",
- "group": "engine",
- "message": "Engine binary download failed."
-}
\ No newline at end of file
diff --git a/engine/packages/cli/src/engine_runner.rs b/engine/packages/cli/src/engine_runner.rs
index 508f05d5e3..da23d86047 100644
--- a/engine/packages/cli/src/engine_runner.rs
+++ b/engine/packages/cli/src/engine_runner.rs
@@ -29,13 +29,10 @@ pub fn engine_config(engine_binary: Option) -> EngineResolverConfig {
}
/// Whether the CLI may download a release engine binary when none is found
-/// locally. Enabled by default for the CLI; set `RIVETKIT_ENGINE_AUTO_DOWNLOAD`
-/// to `0` or `false` to require a local binary.
+/// locally. Enabled by default for the CLI; set `RIVET_DISABLE_BINARY_DOWNLOAD=1`
+/// or `RIVETKIT_ENGINE_AUTO_DOWNLOAD=0` to require a local binary.
fn engine_auto_download() -> bool {
- match env::var("RIVETKIT_ENGINE_AUTO_DOWNLOAD") {
- Ok(value) => !matches!(value.trim(), "0" | "false" | ""),
- Err(_) => true,
- }
+ rivetkit_engine_process::download::auto_download_from_env(true)
}
/// Path to a rivet-engine binary distributed next to this CLI binary.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d27b11769..c62377e4b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3611,18 +3611,12 @@ importers:
'@opentelemetry/api':
specifier: ^1.1.0
version: 1.9.0
- '@rivet-dev/services':
- specifier: ^0.2.3
- version: 0.2.3
'@rivetkit/bare-ts':
specifier: ^0.6.2
version: 0.6.2
'@rivetkit/engine-api-full':
specifier: workspace:*
version: link:../../../engine/sdks/typescript/api-full
- '@rivetkit/engine-cli':
- specifier: workspace:*
- version: link:../engine-cli
'@rivetkit/engine-envoy-protocol':
specifier: workspace:*
version: link:../../../engine/sdks/typescript/envoy-protocol
@@ -3690,6 +3684,9 @@ importers:
'@opentelemetry/sdk-trace-node':
specifier: 2.11.0
version: 2.11.0(@opentelemetry/api@1.9.0)
+ '@rivetkit/engine-cli':
+ specifier: workspace:*
+ version: link:../engine-cli
'@standard-schema/spec':
specifier: ^1.0.0
version: 1.0.0
@@ -8819,41 +8816,6 @@ packages:
resolution: {integrity: sha512-MCwRS12jbgzIEjVYwHXpHjDrkBU6tjSVCIsepZY8SyBM+1VeZpKAfzLi9zOdMqowHftkOP1bx2Bn8RBr/tSeLQ==}
engines: {node: '>=22.19.0'}
- '@rivet-dev/services-darwin-arm64@0.2.3':
- resolution: {integrity: sha512-rOqSAf+yyUX7k9E5vKyqvUboHKQxBriXTj+vphOVbhTiE2lHX2B78WIowwJQfjLqThasekqiVr7tjLpfA+HSGg==}
- engines: {node: '>=20'}
- cpu: [arm64]
- os: [darwin]
-
- '@rivet-dev/services-darwin-x64@0.2.3':
- resolution: {integrity: sha512-BZe5Pek1sb3907BHHRKCx70TYOcnyixMWpiBxAe40ybiYjdZyD2H2/IJCpA8hTp5VrQEKgFbP+5xvyBmfeDJGg==}
- engines: {node: '>=20'}
- cpu: [x64]
- os: [darwin]
-
- '@rivet-dev/services-linux-arm64-gnu@0.2.3':
- resolution: {integrity: sha512-XkJ1hE+FmEoNfZZNe65VqrmpIebgDS2O9j0j13ZeTy2MRP6jAWZfzxZYLMRRWTqTlR5HBrb7pFJQjFWsD1M4lQ==}
- engines: {node: '>=20'}
- cpu: [arm64]
- os: [linux]
-
- '@rivet-dev/services-linux-x64-gnu@0.2.3':
- resolution: {integrity: sha512-1XOj6vb4nusU8AzJvhxnUJnW3FrTK5E495gvjKPNbWGnpHR18UOXWoAG0jU8TTggRf3iwYCZi+QSyjd4cCs42w==}
- engines: {node: '>=20'}
- cpu: [x64]
- os: [linux]
-
- '@rivet-dev/services-win32-x64@0.2.3':
- resolution: {integrity: sha512-8xXY5Ic1/DxXA8eMbsHU4QugJS+u2KCPCL/0yb+/47Ogvty8/8wLjZcRBdDD82rzTX32oW+JPdmjg8iVvWBZqA==}
- engines: {node: '>=20'}
- cpu: [x64]
- os: [win32]
-
- '@rivet-dev/services@0.2.3':
- resolution: {integrity: sha512-4aLKlFgykfM4om6Ag6ySKe7UuO4C3JDCHIdKxft+6Sx8tvVqakFu3rQ+5i6rCMrOzOPGOBtSGPs7UJWqVqe38g==}
- engines: {node: '>=20'}
- hasBin: true
-
'@rivet-gg/cloud@https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@1330':
resolution: {tarball: https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@1330}
version: 0.0.0
@@ -24744,29 +24706,6 @@ snapshots:
- zod-openapi
- zod-to-json-schema
- '@rivet-dev/services-darwin-arm64@0.2.3':
- optional: true
-
- '@rivet-dev/services-darwin-x64@0.2.3':
- optional: true
-
- '@rivet-dev/services-linux-arm64-gnu@0.2.3':
- optional: true
-
- '@rivet-dev/services-linux-x64-gnu@0.2.3':
- optional: true
-
- '@rivet-dev/services-win32-x64@0.2.3':
- optional: true
-
- '@rivet-dev/services@0.2.3':
- optionalDependencies:
- '@rivet-dev/services-darwin-arm64': 0.2.3
- '@rivet-dev/services-darwin-x64': 0.2.3
- '@rivet-dev/services-linux-arm64-gnu': 0.2.3
- '@rivet-dev/services-linux-x64-gnu': 0.2.3
- '@rivet-dev/services-win32-x64': 0.2.3
-
'@rivet-gg/cloud@https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@1330':
dependencies:
cross-fetch: 4.1.0
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_mismatch.json b/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_mismatch.json
new file mode 100644
index 0000000000..43b5795528
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_mismatch.json
@@ -0,0 +1,5 @@
+{
+ "code": "checksum_mismatch",
+ "group": "binary_download",
+ "message": "Binary checksum mismatch."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_missing.json b/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_missing.json
new file mode 100644
index 0000000000..e8137c457e
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.checksum_missing.json
@@ -0,0 +1,5 @@
+{
+ "code": "checksum_missing",
+ "group": "binary_download",
+ "message": "Binary checksum is missing from the release manifest."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.disabled.json b/rivetkit-rust/engine/artifacts/errors/binary_download.disabled.json
new file mode 100644
index 0000000000..87329121ec
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.disabled.json
@@ -0,0 +1,5 @@
+{
+ "code": "disabled",
+ "group": "binary_download",
+ "message": "Binary downloads are disabled."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.http_status.json b/rivetkit-rust/engine/artifacts/errors/binary_download.http_status.json
new file mode 100644
index 0000000000..8aa50af30f
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.http_status.json
@@ -0,0 +1,5 @@
+{
+ "code": "http_status",
+ "group": "binary_download",
+ "message": "Binary download returned an HTTP error."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.incomplete.json b/rivetkit-rust/engine/artifacts/errors/binary_download.incomplete.json
new file mode 100644
index 0000000000..b0ffb10255
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.incomplete.json
@@ -0,0 +1,5 @@
+{
+ "code": "incomplete",
+ "group": "binary_download",
+ "message": "Binary download was incomplete."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.invalid_version.json b/rivetkit-rust/engine/artifacts/errors/binary_download.invalid_version.json
new file mode 100644
index 0000000000..4721cf7a62
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.invalid_version.json
@@ -0,0 +1,5 @@
+{
+ "code": "invalid_version",
+ "group": "binary_download",
+ "message": "Binary version is invalid."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.io.json b/rivetkit-rust/engine/artifacts/errors/binary_download.io.json
new file mode 100644
index 0000000000..42b536b48f
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.io.json
@@ -0,0 +1,5 @@
+{
+ "code": "io",
+ "group": "binary_download",
+ "message": "Binary cache could not be written."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.lock_timeout.json b/rivetkit-rust/engine/artifacts/errors/binary_download.lock_timeout.json
new file mode 100644
index 0000000000..5de422f8c0
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.lock_timeout.json
@@ -0,0 +1,5 @@
+{
+ "code": "lock_timeout",
+ "group": "binary_download",
+ "message": "Timed out waiting for another binary download."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.request_failed.json b/rivetkit-rust/engine/artifacts/errors/binary_download.request_failed.json
new file mode 100644
index 0000000000..00d8b73512
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.request_failed.json
@@ -0,0 +1,5 @@
+{
+ "code": "request_failed",
+ "group": "binary_download",
+ "message": "Binary download request failed."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.unpinned_version.json b/rivetkit-rust/engine/artifacts/errors/binary_download.unpinned_version.json
new file mode 100644
index 0000000000..a04f0b93c2
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.unpinned_version.json
@@ -0,0 +1,5 @@
+{
+ "code": "unpinned_version",
+ "group": "binary_download",
+ "message": "Binary version is not pinned."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/binary_download.unsupported_platform.json b/rivetkit-rust/engine/artifacts/errors/binary_download.unsupported_platform.json
new file mode 100644
index 0000000000..dcef03cabb
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/binary_download.unsupported_platform.json
@@ -0,0 +1,5 @@
+{
+ "code": "unsupported_platform",
+ "group": "binary_download",
+ "message": "No release binary exists for this platform."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/engine.checksum_mismatch.json b/rivetkit-rust/engine/artifacts/errors/engine.checksum_mismatch.json
deleted file mode 100644
index 089c1ef13f..0000000000
--- a/rivetkit-rust/engine/artifacts/errors/engine.checksum_mismatch.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "code": "checksum_mismatch",
- "group": "engine",
- "message": "Engine binary checksum mismatch."
-}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/engine.download_failed.json b/rivetkit-rust/engine/artifacts/errors/engine.download_failed.json
deleted file mode 100644
index e10e382f46..0000000000
--- a/rivetkit-rust/engine/artifacts/errors/engine.download_failed.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "code": "download_failed",
- "group": "engine",
- "message": "Engine binary download failed."
-}
\ No newline at end of file
diff --git a/rivetkit-rust/packages/engine-process/Cargo.toml b/rivetkit-rust/packages/engine-process/Cargo.toml
index cdbc005aee..b95befac11 100644
--- a/rivetkit-rust/packages/engine-process/Cargo.toml
+++ b/rivetkit-rust/packages/engine-process/Cargo.toml
@@ -11,6 +11,7 @@ description = "Resolves, spawns, and reuses the rivet-engine subprocess for loca
[dependencies]
anyhow.workspace = true
+dirs.workspace = true
reqwest.workspace = true
rivet-error.workspace = true
serde.workspace = true
diff --git a/rivetkit-rust/packages/engine-process/src/download.rs b/rivetkit-rust/packages/engine-process/src/download.rs
new file mode 100644
index 0000000000..94c2f4221d
--- /dev/null
+++ b/rivetkit-rust/packages/engine-process/src/download.rs
@@ -0,0 +1,1154 @@
+//! On-demand download of release binaries (the Rivet Engine and Services)
+//! from releases.rivet.dev for local development.
+//!
+//! Binaries are cached per user, keyed by product, version, and platform. A
+//! download is verified against the release's `SHA256SUMS` manifest before it
+//! is atomically renamed into the cache, and a per-binary file lock keeps
+//! parallel processes from corrupting or duplicating the download.
+
+use std::fs::{File, OpenOptions};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use reqwest::{Client, StatusCode, Url};
+use rivet_error::RivetError;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+/// Base URL that hosts Rivet release artifacts.
+pub const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev";
+/// Set to `1` to forbid all binary downloads.
+pub const DISABLE_DOWNLOAD_ENV: &str = "RIVET_DISABLE_BINARY_DOWNLOAD";
+/// Turns binary downloads on (`1`) or off (`0`).
+pub const AUTO_DOWNLOAD_ENV: &str = "RIVETKIT_ENGINE_AUTO_DOWNLOAD";
+/// Overrides the per-user cache directory that downloaded binaries live in.
+pub const CACHE_DIR_ENV: &str = "RIVET_BINARY_CACHE_DIR";
+const CHECKSUM_MANIFEST: &str = "SHA256SUMS";
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
+const MANIFEST_TIMEOUT: Duration = Duration::from_secs(30);
+/// Maximum time to wait for the next chunk of the binary before giving up.
+const CHUNK_TIMEOUT: Duration = Duration::from_secs(60);
+/// How often a running download logs its progress.
+const PROGRESS_INTERVAL: Duration = Duration::from_secs(10);
+/// Retries after the first attempt for transient download failures.
+const DOWNLOAD_RETRIES: u32 = 3;
+/// Delay before the first retry. It doubles for each later retry.
+const RETRY_BASE_DELAY: Duration = if cfg!(test) {
+ Duration::from_millis(10)
+} else {
+ Duration::from_secs(1)
+};
+/// Longest `Retry-After` delay honored for a rate-limited download.
+const MAX_RETRY_AFTER: Duration = if cfg!(test) {
+ Duration::from_millis(50)
+} else {
+ Duration::from_secs(30)
+};
+/// Maximum time to wait for another process that is downloading the same binary.
+const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
+/// How often to retry a contended download lock. File locks have no async
+/// wakeup, so the waiter retries `try_lock` on this interval.
+const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(500);
+
+/// A release binary that can be downloaded on demand.
+#[derive(Clone, Debug)]
+pub struct ReleaseBinary {
+ /// Product name shown in logs, for example `Rivet Engine`.
+ pub display_name: &'static str,
+ /// Cache subdirectory, for example `engine`.
+ pub cache_name: &'static str,
+ /// Exact release version to download. Never `latest`.
+ pub version: String,
+ /// Platform label shown in logs and used as a cache key.
+ pub platform: String,
+ /// Directory URL that holds the artifact and its `SHA256SUMS` manifest.
+ pub base_url: String,
+ /// File name of the artifact in `base_url`.
+ pub artifact: String,
+ /// File name of the binary in the cache.
+ pub file_name: String,
+ /// Environment variable that points at a pre-downloaded binary instead.
+ pub override_env: &'static str,
+ /// Extra workaround offered when the download fails.
+ pub alternative: &'static str,
+}
+
+impl ReleaseBinary {
+ pub fn artifact_url(&self) -> String {
+ format!("{}/{}", self.base_url.trim_end_matches('/'), self.artifact)
+ }
+
+ pub fn manifest_url(&self) -> String {
+ format!(
+ "{}/{CHECKSUM_MANIFEST}",
+ self.base_url.trim_end_matches('/')
+ )
+ }
+
+ /// Final location of the verified binary under `cache_root`.
+ pub fn cache_path(&self, cache_root: &Path) -> PathBuf {
+ cache_root
+ .join(self.cache_name)
+ .join(&self.version)
+ .join(&self.platform)
+ .join(&self.file_name)
+ }
+
+ /// Whether `cache_root` holds a complete, verified copy of this binary.
+ ///
+ /// A cached binary counts only when its checksum record exists and its size
+ /// matches the verified download, so a truncated or replaced file is
+ /// downloaded again instead of being run.
+ pub fn is_cached(&self, cache_root: &Path) -> bool {
+ cached_binary_is_valid(&self.cache_path(cache_root))
+ }
+
+ /// Ways to avoid downloading this binary, shown once for slow downloads.
+ fn skip_hint(&self) -> String {
+ format!(
+ "set {} to a pre-downloaded binary, set {CACHE_DIR_ENV} to a cache directory that persists across runs, or {}",
+ self.override_env, self.alternative
+ )
+ }
+
+ fn workaround(&self) -> String {
+ format!(
+ "Download {} manually, verify it against {}, and set {} to its path, or {}",
+ self.artifact_url(),
+ self.manifest_url(),
+ self.override_env,
+ self.alternative
+ )
+ }
+}
+
+#[derive(RivetError, Debug, Clone, Deserialize, Serialize)]
+#[error("binary_download")]
+pub enum BinaryDownloadError {
+ #[error(
+ "disabled",
+ "Binary downloads are disabled.",
+ "{binary} {version} is not cached and downloads are disabled by {env}=1. {workaround}"
+ )]
+ Disabled {
+ binary: String,
+ version: String,
+ env: String,
+ workaround: String,
+ },
+
+ #[error(
+ "unsupported_platform",
+ "No release binary exists for this platform.",
+ "{binary} is not published for {os}-{arch}. Set {override_env} to a binary built for this platform, or {alternative}"
+ )]
+ UnsupportedPlatform {
+ binary: String,
+ os: String,
+ arch: String,
+ override_env: String,
+ alternative: String,
+ },
+
+ #[error(
+ "request_failed",
+ "Binary download request failed.",
+ "Could not download {binary} {version} from {url}: {reason}. Check your network connection or proxy settings. {workaround}"
+ )]
+ RequestFailed {
+ binary: String,
+ version: String,
+ url: String,
+ reason: String,
+ workaround: String,
+ },
+
+ #[error(
+ "http_status",
+ "Binary download returned an HTTP error.",
+ "Could not download {binary} {version} from {url}: HTTP {status}. {workaround}"
+ )]
+ HttpStatus {
+ binary: String,
+ version: String,
+ url: String,
+ status: u16,
+ workaround: String,
+ },
+
+ #[error(
+ "checksum_missing",
+ "Binary checksum is missing from the release manifest.",
+ "The release manifest {url} has no checksum for {artifact}, so {binary} {version} cannot be verified and will not run. {workaround}"
+ )]
+ ChecksumMissing {
+ binary: String,
+ version: String,
+ url: String,
+ artifact: String,
+ workaround: String,
+ },
+
+ #[error(
+ "checksum_mismatch",
+ "Binary checksum mismatch.",
+ "Downloaded {binary} {version} from {url} does not match its published SHA-256 checksum (expected {expected}, received {received}). The file was discarded. Retry, or {workaround}"
+ )]
+ ChecksumMismatch {
+ binary: String,
+ version: String,
+ url: String,
+ expected: String,
+ received: String,
+ workaround: String,
+ },
+
+ #[error(
+ "incomplete",
+ "Binary download was incomplete.",
+ "Download of {binary} {version} from {url} ended after {received_bytes} of {expected_bytes} bytes. The partial file was discarded. Retry, or {workaround}"
+ )]
+ Incomplete {
+ binary: String,
+ version: String,
+ url: String,
+ received_bytes: u64,
+ expected_bytes: u64,
+ workaround: String,
+ },
+
+ #[error(
+ "invalid_version",
+ "Binary version is invalid.",
+ "{binary} version `{version}` is not a valid release version. A version may contain only ASCII letters, digits, `.`, `-`, `+`, and `_`, and must start with a letter or digit."
+ )]
+ InvalidVersion { binary: String, version: String },
+
+ #[error(
+ "unpinned_version",
+ "Binary version is not pinned.",
+ "{binary} version `{version}` is not a pinned release version. Set an exact version such as `2.3.30`. Aliases such as `latest` are not supported because a cached alias would never update."
+ )]
+ UnpinnedVersion { binary: String, version: String },
+
+ #[error(
+ "lock_timeout",
+ "Timed out waiting for another binary download.",
+ "Timed out after {timeout_secs}s waiting for another process to finish downloading {binary} {version}. That process holds the lock `{lock_path}`. Stop it and retry, or {workaround}"
+ )]
+ LockTimeout {
+ binary: String,
+ version: String,
+ lock_path: String,
+ timeout_secs: u64,
+ workaround: String,
+ },
+
+ #[error(
+ "io",
+ "Binary cache could not be written.",
+ "Could not {operation} `{path}` while installing {binary} {version}: {reason}. Check that the directory is writable and has free space, set {cache_env} to a writable directory, or {workaround}"
+ )]
+ Io {
+ binary: String,
+ version: String,
+ operation: String,
+ path: String,
+ reason: String,
+ cache_env: String,
+ workaround: String,
+ },
+}
+
+/// Whether `RIVET_DISABLE_BINARY_DOWNLOAD` forbids downloads.
+pub fn download_disabled_by_env() -> bool {
+ std::env::var(DISABLE_DOWNLOAD_ENV)
+ .map(|value| is_truthy(&value))
+ .unwrap_or(false)
+}
+
+/// Whether a missing binary may be downloaded, from the environment.
+///
+/// `RIVET_DISABLE_BINARY_DOWNLOAD=1` always wins. Otherwise
+/// `RIVETKIT_ENGINE_AUTO_DOWNLOAD` can turn downloads on or off, and `default`
+/// applies when neither is set.
+pub fn auto_download_from_env(default: bool) -> bool {
+ resolve_auto_download(None, default, |key| std::env::var(key).ok())
+}
+
+/// Whether a missing binary may be downloaded.
+///
+/// `RIVET_DISABLE_BINARY_DOWNLOAD=1` always wins. Otherwise the explicit config
+/// value applies, then `RIVETKIT_ENGINE_AUTO_DOWNLOAD`, then `default`.
+pub fn resolve_auto_download(
+ explicit: Option,
+ default: bool,
+ env: impl Fn(&str) -> Option,
+) -> bool {
+ if env(DISABLE_DOWNLOAD_ENV).is_some_and(|value| is_truthy(&value)) {
+ return false;
+ }
+ if let Some(explicit) = explicit {
+ return explicit;
+ }
+ match env(AUTO_DOWNLOAD_ENV) {
+ Some(value) if !value.trim().is_empty() => is_truthy(&value),
+ Some(_) | None => default,
+ }
+}
+
+/// Whether an environment flag is on: `1`, `true`, `yes`, or `on`.
+pub fn is_truthy(value: &str) -> bool {
+ matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "yes" | "on"
+ )
+}
+
+/// Rejects release versions that could escape the cache directory or the
+/// release URL, such as `..` or values containing path separators.
+pub fn validate_version(display_name: &str, version: &str) -> Result<()> {
+ let starts_alphanumeric = version
+ .chars()
+ .next()
+ .is_some_and(|first| first.is_ascii_alphanumeric());
+ let allowed = version
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'));
+ if starts_alphanumeric && allowed {
+ return Ok(());
+ }
+ Err(BinaryDownloadError::InvalidVersion {
+ binary: display_name.to_owned(),
+ version: version.to_owned(),
+ }
+ .build())
+}
+
+/// Rejects versions that are not exact releases, such as `latest` or `main`.
+/// Exact versions start with a digit, for example `2.3.30` or `2.3.30-rc.1`.
+pub fn validate_pinned_version(display_name: &str, version: &str) -> Result<()> {
+ validate_version(display_name, version)?;
+ if version.starts_with(|c: char| c.is_ascii_digit()) {
+ return Ok(());
+ }
+ Err(BinaryDownloadError::UnpinnedVersion {
+ binary: display_name.to_owned(),
+ version: version.to_owned(),
+ }
+ .build())
+}
+
+/// Per-user cache root for downloaded binaries.
+///
+/// Honors `RIVET_BINARY_CACHE_DIR`, then the platform cache directory
+/// (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS,
+/// `%LOCALAPPDATA%` on Windows), then `~/.cache`.
+pub fn binary_cache_root() -> Result {
+ if let Some(path) = std::env::var_os(CACHE_DIR_ENV).filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(path));
+ }
+ if let Some(path) = dirs::cache_dir() {
+ return Ok(path.join("rivet"));
+ }
+ let home = dirs::home_dir().ok_or_else(|| {
+ anyhow::anyhow!(
+ "could not locate a cache directory for downloaded binaries; set {CACHE_DIR_ENV}"
+ )
+ })?;
+ Ok(home.join(".cache").join("rivet"))
+}
+
+/// Returns the cached binary, downloading and verifying it first if needed.
+///
+/// `reason` explains in logs why the binary is needed. When `allow_download`
+/// is false, a missing binary fails with an error that explains what to set
+/// instead. Every failure is logged once here with its resolution.
+pub async fn ensure_release_binary(
+ binary: &ReleaseBinary,
+ cache_root: &Path,
+ allow_download: bool,
+ reason: &'static str,
+) -> Result {
+ let result = install_release_binary(binary, cache_root, allow_download, reason).await;
+ if let Err(error) = &result {
+ tracing::error!(
+ binary = binary.display_name,
+ version = %binary.version,
+ %error,
+ "could not install binary"
+ );
+ }
+ result
+}
+
+async fn install_release_binary(
+ binary: &ReleaseBinary,
+ cache_root: &Path,
+ allow_download: bool,
+ reason: &'static str,
+) -> Result {
+ validate_version(binary.display_name, &binary.version)?;
+ let destination = binary.cache_path(cache_root);
+ if cached_binary_is_valid(&destination) {
+ tracing::debug!(
+ binary = binary.display_name,
+ version = %binary.version,
+ path = %destination.display(),
+ "using cached binary"
+ );
+ return Ok(destination);
+ }
+
+ if !allow_download {
+ return Err(BinaryDownloadError::Disabled {
+ binary: binary.display_name.to_owned(),
+ version: binary.version.clone(),
+ env: DISABLE_DOWNLOAD_ENV.to_owned(),
+ workaround: binary.workaround(),
+ }
+ .build());
+ }
+
+ let directory = destination
+ .parent()
+ .ok_or_else(|| io_error(binary, "resolve the parent of", &destination, "no parent"))?
+ .to_path_buf();
+ std::fs::create_dir_all(&directory)
+ .map_err(|error| io_error(binary, "create directory", &directory, error))?;
+
+ let lock_path = directory.join(format!("{}.lock", binary.file_name));
+ let lock = acquire_lock(binary, &lock_path, LOCK_WAIT_TIMEOUT).await?;
+
+ // Another process may have finished the download while this one waited.
+ if cached_binary_is_valid(&destination) {
+ tracing::info!(
+ binary = binary.display_name,
+ version = %binary.version,
+ path = %destination.display(),
+ "binary was downloaded by another process"
+ );
+ return Ok(destination);
+ }
+
+ if destination.exists() {
+ tracing::warn!(
+ binary = binary.display_name,
+ version = %binary.version,
+ path = %destination.display(),
+ "cached binary is incomplete or unverified, likely from an interrupted install or a modified cache; downloading it again"
+ );
+ }
+
+ // Without the lock another process may own a temp file in this directory.
+ if lock.is_some() {
+ remove_stale_downloads(&directory, &binary.file_name);
+ }
+ download_verified(binary, &destination, reason).await?;
+ drop(lock);
+ Ok(destination)
+}
+
+/// Path of the checksum record written next to a verified binary.
+pub(crate) fn checksum_record_path(destination: &Path) -> PathBuf {
+ let mut name = destination
+ .file_name()
+ .map(|name| name.to_os_string())
+ .unwrap_or_default();
+ name.push(".sha256");
+ destination.with_file_name(name)
+}
+
+/// Whether `destination` exists and matches the size in its checksum record.
+fn cached_binary_is_valid(destination: &Path) -> bool {
+ let Ok(metadata) = std::fs::metadata(destination) else {
+ return false;
+ };
+ if !metadata.is_file() {
+ return false;
+ }
+ let Ok(record) = std::fs::read_to_string(checksum_record_path(destination)) else {
+ return false;
+ };
+ let mut parts = record.split_whitespace();
+ let checksum_ok = parts.next().is_some_and(|checksum| checksum.len() == 64);
+ let size = parts.next().and_then(|size| size.parse::().ok());
+ checksum_ok && size == Some(metadata.len())
+}
+
+/// Removes a temporary download when dropped, including when the download
+/// future is cancelled, unless the file was installed.
+struct TempDownload {
+ path: PathBuf,
+ installed: bool,
+}
+
+impl Drop for TempDownload {
+ fn drop(&mut self) {
+ if !self.installed && std::fs::remove_file(&self.path).is_ok() {
+ tracing::info!(
+ path = %self.path.display(),
+ "discarded incomplete download"
+ );
+ }
+ }
+}
+
+/// Unique temp file next to `destination`. The process id and a timestamp keep
+/// concurrent downloads apart when locking is unavailable.
+fn temp_download_path(destination: &Path, file_name: &str) -> PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|elapsed| elapsed.as_nanos())
+ .unwrap_or_default();
+ destination.with_file_name(format!(
+ ".{file_name}.{}-{nanos}.download",
+ std::process::id()
+ ))
+}
+
+/// Removes temporary files left by downloads that were interrupted, for
+/// example when the process was killed. Only called while holding the
+/// download lock, so no live download owns them.
+fn remove_stale_downloads(directory: &Path, file_name: &str) {
+ let prefix = format!(".{file_name}.");
+ let Ok(entries) = std::fs::read_dir(directory) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let name = entry.file_name();
+ let name = name.to_string_lossy();
+ if name.starts_with(&prefix)
+ && name.ends_with(".download")
+ && std::fs::remove_file(entry.path()).is_ok()
+ {
+ tracing::info!(
+ path = %entry.path().display(),
+ "removed a partial download left by an interrupted run"
+ );
+ }
+ }
+}
+
+/// Exclusive per-binary lock file held for the duration of a download.
+struct DownloadLock {
+ file: File,
+}
+
+impl Drop for DownloadLock {
+ fn drop(&mut self) {
+ let _ = self.file.unlock();
+ }
+}
+
+/// Whether a lock error means the filesystem cannot lock files at all, for
+/// example some network or FUSE mounts, rather than a real I/O failure.
+pub(crate) fn lock_unsupported(error: &std::io::Error) -> bool {
+ if error.kind() == std::io::ErrorKind::Unsupported {
+ return true;
+ }
+ #[cfg(unix)]
+ {
+ matches!(
+ error.raw_os_error(),
+ Some(libc::ENOLCK | libc::EOPNOTSUPP | libc::ENOSYS)
+ ) || error.raw_os_error() == Some(libc::ENOTSUP)
+ }
+ #[cfg(not(unix))]
+ {
+ false
+ }
+}
+
+/// Takes the per-binary download lock. Returns `None` when the filesystem does
+/// not support locking, in which case the download proceeds unlocked.
+async fn acquire_lock(
+ binary: &ReleaseBinary,
+ lock_path: &Path,
+ timeout: Duration,
+) -> Result