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> { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(lock_path) + .map_err(|error| io_error(binary, "open lock file", lock_path, error))?; + + let deadline = tokio::time::Instant::now() + timeout; + let mut retry = tokio::time::interval_at( + tokio::time::Instant::now() + LOCK_RETRY_INTERVAL, + LOCK_RETRY_INTERVAL, + ); + retry.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut logged_wait = false; + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(DownloadLock { file })), + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(error)) if lock_unsupported(&error) => { + tracing::warn!( + binary = binary.display_name, + version = %binary.version, + lock_path = %lock_path.display(), + %error, + hint = %format!("set {CACHE_DIR_ENV} to a directory on a local filesystem"), + "file locking is not supported in the binary cache directory, so parallel first runs may each download the binary" + ); + return Ok(None); + } + Err(std::fs::TryLockError::Error(error)) => { + return Err(io_error(binary, "lock", lock_path, error)); + } + } + + if !logged_wait { + logged_wait = true; + tracing::info!( + binary = binary.display_name, + version = %binary.version, + lock_path = %lock_path.display(), + timeout_secs = timeout.as_secs(), + "waiting for another process to finish downloading this binary" + ); + } + if tokio::time::Instant::now() >= deadline { + return Err(BinaryDownloadError::LockTimeout { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + lock_path: lock_path.display().to_string(), + timeout_secs: timeout.as_secs(), + workaround: binary.workaround(), + } + .build()); + } + retry.tick().await; + } +} + +async fn download_verified( + binary: &ReleaseBinary, + destination: &Path, + reason: &'static str, +) -> Result<()> { + let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|error| request_error(binary, &binary.manifest_url(), error))?; + + let url = binary.artifact_url(); + let host = Url::parse(&url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_default(); + tracing::info!( + binary = binary.display_name, + version = %binary.version, + platform = %binary.platform, + source = %host, + reason, + %url, + "downloading binary (first run only, cached afterwards)" + ); + let started = Instant::now(); + + let manifest_url = binary.manifest_url(); + let client = &client; + let manifest = with_retries(binary, &manifest_url, |_| { + fetch_manifest(client, binary, &manifest_url) + }) + .await?; + let expected = checksum_for_artifact(&manifest, &binary.artifact).ok_or_else(|| { + BinaryDownloadError::ChecksumMissing { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: manifest_url.clone(), + artifact: binary.artifact.clone(), + workaround: binary.workaround(), + } + .build() + })?; + + let (mut temp, size) = with_retries(binary, &url, |attempt| { + download_attempt(client, binary, &url, destination, &expected, attempt) + }) + .await?; + + // Remove the old record first so a replaced binary is never paired with a + // stale size, then record the verified size only after the rename. + let record_path = checksum_record_path(destination); + match std::fs::remove_file(&record_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(binary, "remove", &record_path, error)), + } + make_executable(&temp.path) + .and_then(|()| std::fs::rename(&temp.path, destination)) + .map_err(|error| io_error(binary, "install", destination, error))?; + temp.installed = true; + write_checksum_record(&record_path, &expected, size) + .map_err(|error| io_error(binary, "write", &record_path, error))?; + sync_directory(destination); + + tracing::info!( + binary = binary.display_name, + version = %binary.version, + platform = %binary.platform, + path = %destination.display(), + size_mb = bytes_to_mb(size), + elapsed_secs = round_tenths(started.elapsed().as_secs_f64()), + "downloaded and verified binary" + ); + Ok(()) +} + +/// One attempt at downloading the artifact into a verified temp file. +async fn download_attempt( + client: &Client, + binary: &ReleaseBinary, + url: &str, + destination: &Path, + expected: &str, + attempt: u32, +) -> std::result::Result<(TempDownload, u64), AttemptError> { + // The body has its own per-chunk timeout, so bound only the wait for the + // response headers here instead of the whole transfer. + let mut response = match tokio::time::timeout(CHUNK_TIMEOUT, client.get(url).send()).await { + Ok(response) => response.map_err(|error| request_error(binary, url, error))?, + Err(_) => { + return Err(request_error( + binary, + url, + format!("no response received for {}s", CHUNK_TIMEOUT.as_secs()), + ) + .into()); + } + }; + check_status(binary, url, &response)?; + let total_bytes = response.content_length(); + tracing::info!( + binary = binary.display_name, + version = %binary.version, + attempt, + size_mb = total_bytes.map(bytes_to_mb), + "download started" + ); + + let temp = TempDownload { + path: temp_download_path(destination, &binary.file_name), + installed: false, + }; + let size = stream_to_file( + binary, + url, + &mut response, + &temp.path, + total_bytes, + expected, + ) + .await?; + Ok((temp, size)) +} + +/// A failed download attempt plus the `Retry-After` delay the server asked for. +pub(crate) struct AttemptError { + pub(crate) error: anyhow::Error, + pub(crate) retry_after: Option, +} + +impl From for AttemptError { + fn from(error: anyhow::Error) -> Self { + Self { + error, + retry_after: None, + } + } +} + +/// Runs `attempt` with up to `DOWNLOAD_RETRIES` retries and exponential +/// backoff for transient failures. Missing releases and checksum mismatches +/// fail immediately because retrying cannot fix them. +async fn with_retries(binary: &ReleaseBinary, url: &str, mut attempt: F) -> Result +where + F: FnMut(u32) -> Fut, + Fut: std::future::Future>, +{ + let mut delay = RETRY_BASE_DELAY; + let mut number = 1; + loop { + match attempt(number).await { + Ok(value) => return Ok(value), + Err(AttemptError { error, retry_after }) if is_retryable(&error) && number <= DOWNLOAD_RETRIES => { + let wait = retry_after.unwrap_or(delay); + tracing::warn!( + binary = binary.display_name, + version = %binary.version, + %url, + attempt = number, + max_attempts = DOWNLOAD_RETRIES + 1, + retry_in_secs = wait.as_secs_f64(), + %error, + "binary download failed with a transient error, retrying" + ); + tokio::time::sleep(wait).await; + delay *= 2; + number += 1; + } + Err(AttemptError { error, .. }) => { + if is_retryable(&error) { + tracing::warn!( + binary = binary.display_name, + version = %binary.version, + %url, + attempts = number, + "binary download retries exhausted" + ); + } + return Err(error); + } + } + } +} + +/// Whether a download error is transient: a connection failure, a timeout, an +/// interrupted body, rate limiting, or a server error. +pub(crate) fn is_retryable(error: &anyhow::Error) -> bool { + let error = rivet_error::RivetError::extract(error); + if error.group() != "binary_download" { + return false; + } + match error.code() { + "request_failed" | "incomplete" => true, + "http_status" => error + .metadata() + .and_then(|metadata| metadata.get("status").and_then(|status| status.as_u64())) + .is_some_and(|status| status == 429 || status >= 500), + _ => false, + } +} + +/// The `Retry-After` delay of a rate-limited response, capped at +/// `MAX_RETRY_AFTER`. Only the delay-in-seconds form is supported; the +/// HTTP-date form falls back to exponential backoff. +pub(crate) fn retry_after(status: StatusCode, headers: &reqwest::header::HeaderMap) -> Option { + if status != StatusCode::TOO_MANY_REQUESTS { + return None; + } + let secs: u64 = headers + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok()?; + Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER)) +} + +/// Streams the response body to `temp_path` while hashing it, then checks the +/// length and checksum. Returns the number of bytes written. +async fn stream_to_file( + binary: &ReleaseBinary, + url: &str, + response: &mut reqwest::Response, + temp_path: &Path, + total_bytes: Option, + expected: &str, +) -> Result { + let mut file = + File::create(temp_path).map_err(|error| io_error(binary, "create", temp_path, error))?; + let mut hasher = Sha256::new(); + let mut received: u64 = 0; + let started = Instant::now(); + let mut last_progress = started; + let mut hinted = false; + + loop { + let chunk = match tokio::time::timeout(CHUNK_TIMEOUT, response.chunk()).await { + Ok(Ok(Some(chunk))) => chunk, + Ok(Ok(None)) => break, + Ok(Err(error)) => { + return Err(incomplete_or_request_error( + binary, + url, + received, + total_bytes, + error.to_string(), + )); + } + Err(_) => { + return Err(incomplete_or_request_error( + binary, + url, + received, + total_bytes, + format!("no data received for {}s", CHUNK_TIMEOUT.as_secs()), + )); + } + }; + hasher.update(&chunk); + file.write_all(&chunk) + .map_err(|error| io_error(binary, "write", temp_path, error))?; + received += chunk.len() as u64; + + if last_progress.elapsed() >= PROGRESS_INTERVAL { + last_progress = Instant::now(); + log_progress(binary, received, total_bytes, started.elapsed()); + if !hinted { + hinted = true; + tracing::info!( + binary = binary.display_name, + hint = %binary.skip_hint(), + "this download happens once per version; to skip it on other machines or containers" + ); + } + } + } + + if let Some(expected_bytes) = total_bytes + && received != expected_bytes + { + return Err(BinaryDownloadError::Incomplete { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: url.to_owned(), + received_bytes: received, + expected_bytes, + workaround: binary.workaround(), + } + .build()); + } + + file.sync_all() + .map_err(|error| io_error(binary, "flush", temp_path, error))?; + drop(file); + + let received_hash = hex(&hasher.finalize()); + if !received_hash.eq_ignore_ascii_case(expected) { + return Err(BinaryDownloadError::ChecksumMismatch { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: url.to_owned(), + expected: expected.to_owned(), + received: received_hash, + workaround: binary.workaround(), + } + .build()); + } + + Ok(received) +} + +pub(crate) fn write_checksum_record( + path: &Path, + checksum: &str, + size: u64, +) -> std::io::Result<()> { + let mut file = File::create(path)?; + file.write_all(format!("{} {size}\n", checksum.to_ascii_lowercase()).as_bytes())?; + file.sync_all() +} + +/// Flushes the directory entry for a renamed file so the install survives a +/// crash. Best effort, because some platforms cannot open directories. +fn sync_directory(path: &Path) { + #[cfg(unix)] + if let Some(directory) = path.parent() + && let Err(error) = File::open(directory).and_then(|directory| directory.sync_all()) + { + tracing::debug!( + path = %directory.display(), + %error, + "could not sync binary cache directory" + ); + } + #[cfg(not(unix))] + { + let _ = path; + } +} + +fn log_progress(binary: &ReleaseBinary, received: u64, total: Option, elapsed: Duration) { + let rate = received as f64 / elapsed.as_secs_f64().max(0.001); + let percent = total + .filter(|total| *total > 0) + .map(|total| (received as f64 / total as f64 * 100.0).round()); + let eta_secs = total + .filter(|total| *total > received && rate > 0.0) + .map(|total| ((total - received) as f64 / rate).round()); + tracing::info!( + binary = binary.display_name, + version = %binary.version, + percent, + downloaded_mb = bytes_to_mb(received), + total_mb = total.map(bytes_to_mb), + speed_mb_per_sec = round_tenths(rate / 1_048_576.0), + eta_secs, + "still downloading binary" + ); +} + +async fn fetch_manifest( + client: &Client, + binary: &ReleaseBinary, + url: &str, +) -> std::result::Result { + let response = client + .get(url) + .timeout(MANIFEST_TIMEOUT) + .send() + .await + .map_err(|error| request_error(binary, url, error))?; + check_status(binary, url, &response)?; + Ok(response + .text() + .await + .map_err(|error| request_error(binary, url, error))?) +} + +fn check_status( + binary: &ReleaseBinary, + url: &str, + response: &reqwest::Response, +) -> std::result::Result<(), AttemptError> { + let status = response.status(); + check_status_code(binary, url, status).map_err(|error| AttemptError { + error, + retry_after: retry_after(status, response.headers()), + }) +} + +pub(crate) fn check_status_code(binary: &ReleaseBinary, url: &str, status: StatusCode) -> Result<()> { + if status.is_success() { + return Ok(()); + } + Err(BinaryDownloadError::HttpStatus { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: url.to_owned(), + status: status.as_u16(), + workaround: binary.workaround(), + } + .build()) +} + +/// Parses a `sha256sum` manifest and returns the checksum for `artifact`. +pub fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { + manifest.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let checksum = parts.next()?; + let name = parts.next()?.trim_start_matches('*'); + if checksum.len() == 64 && name == artifact { + Some(checksum.to_owned()) + } else { + None + } + }) +} + +/// Lowercase hex encoding of a SHA-256 digest. +pub fn sha256_hex(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn bytes_to_mb(bytes: u64) -> f64 { + round_tenths(bytes as f64 / 1_048_576.0) +} + +fn round_tenths(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +fn request_error(binary: &ReleaseBinary, url: &str, error: impl std::fmt::Display) -> anyhow::Error { + BinaryDownloadError::RequestFailed { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: url.to_owned(), + reason: error.to_string(), + workaround: binary.workaround(), + } + .build() +} + +fn incomplete_or_request_error( + binary: &ReleaseBinary, + url: &str, + received: u64, + total: Option, + reason: String, +) -> anyhow::Error { + match total { + Some(expected_bytes) => BinaryDownloadError::Incomplete { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + url: url.to_owned(), + received_bytes: received, + expected_bytes, + workaround: binary.workaround(), + } + .build(), + None => request_error(binary, url, reason), + } +} + +fn io_error( + binary: &ReleaseBinary, + operation: &str, + path: &Path, + error: impl std::fmt::Display, +) -> anyhow::Error { + BinaryDownloadError::Io { + binary: binary.display_name.to_owned(), + version: binary.version.clone(), + operation: operation.to_owned(), + path: path.display().to_string(), + reason: error.to_string(), + cache_env: CACHE_DIR_ENV.to_owned(), + workaround: binary.workaround(), + } + .build() +} + +fn make_executable(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions)?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +/// Returns `UnsupportedPlatform` for binaries not published for this host. +pub fn unsupported_platform( + display_name: &str, + override_env: &str, + alternative: &str, +) -> anyhow::Error { + BinaryDownloadError::UnsupportedPlatform { + binary: display_name.to_owned(), + os: std::env::consts::OS.to_owned(), + arch: std::env::consts::ARCH.to_owned(), + override_env: override_env.to_owned(), + alternative: alternative.to_owned(), + } + .build() +} + +#[cfg(test)] +#[path = "../tests/unit/download.rs"] +mod tests; diff --git a/rivetkit-rust/packages/engine-process/src/error.rs b/rivetkit-rust/packages/engine-process/src/error.rs index 956b2232ce..e261770e67 100644 --- a/rivetkit-rust/packages/engine-process/src/error.rs +++ b/rivetkit-rust/packages/engine-process/src/error.rs @@ -7,35 +7,17 @@ pub enum EngineProcessError { #[error( "binary_not_found", "Engine binary was not found.", - "Engine binary was not found at '{path}'." + "Engine binary was not found at '{path}'. Point RIVET_ENGINE_BINARY_PATH at an existing rivet-engine binary, or unset it so RivetKit downloads the Engine." )] BinaryNotFound { path: String }, #[error( "binary_unavailable", "Engine binary is unavailable.", - "No usable engine binary was found for version '{version}'. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, or enable `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1`." + "No usable engine binary was found for version '{version}' and downloading it is turned off. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, turn downloads back on with `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1` (or `engine_auto_download` in a Rust `ServeConfig`), or set `RIVET_ENDPOINT` to a remote Rivet Engine." )] BinaryUnavailable { version: String }, - #[error( - "download_failed", - "Engine binary download failed.", - "Engine binary download failed for '{url}': {reason}" - )] - DownloadFailed { url: String, reason: String }, - - #[error( - "checksum_mismatch", - "Engine binary checksum mismatch.", - "Engine binary checksum mismatch for '{artifact}': expected {expected}, received {received}." - )] - ChecksumMismatch { - artifact: String, - expected: String, - received: String, - }, - #[error( "invalid_endpoint", "Engine endpoint is invalid.", diff --git a/rivetkit-rust/packages/engine-process/src/lib.rs b/rivetkit-rust/packages/engine-process/src/lib.rs index 51c7f39b52..14df3b5e5a 100644 --- a/rivetkit-rust/packages/engine-process/src/lib.rs +++ b/rivetkit-rust/packages/engine-process/src/lib.rs @@ -6,20 +6,28 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use reqwest::{Client, Url}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use tokio::process::{Child, Command}; use tokio::task::JoinHandle; +pub mod download; mod error; +pub use download::{BinaryDownloadError, ReleaseBinary}; pub use error::EngineProcessError; const ENGINE_RUNTIME: &str = "engine"; const RIVETKIT_RUNTIME: &str = "rivetkit"; -const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; +/// Deprecated alias for `RIVET_RUN_ENGINE_VERSION`. +const LEGACY_ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; +const RUN_ENGINE_VERSION_ENV: &str = "RIVET_RUN_ENGINE_VERSION"; const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT"; -const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev"; -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const ENGINE_BINARY_PATH_ENV: &str = "RIVET_ENGINE_BINARY_PATH"; +/// Publish commit whose Engine a preview build downloads from +/// `{releases}/rivet/{ref}/engine/`. CI sets it only for preview builds that +/// also publish Engine binaries. Releases leave it unset and download by version. +const PREVIEW_ENGINE_REF: Option<&str> = option_env!("RIVET_ENGINE_RELEASE_REF"); +const ENGINE_DOWNLOAD_ALTERNATIVE: &str = + "set RIVET_ENDPOINT to a remote Rivet Engine so no local Engine is needed."; #[derive(Debug, Deserialize)] struct EngineHealthResponse { @@ -37,6 +45,8 @@ pub struct EngineResolverConfig { pub public_url: Option, pub auto_download: bool, pub version: String, + /// Preview publish commit to download the Engine from instead of `version`. + pub preview_ref: Option, pub releases_endpoint: String, } @@ -55,12 +65,45 @@ impl EngineResolverConfig { bind_port, public_url: None, auto_download, - version: std::env::var(ENGINE_VERSION_ENV) - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()), + version: env!("CARGO_PKG_VERSION").to_owned(), + preview_ref: PREVIEW_ENGINE_REF + .filter(|value| !value.is_empty()) + .map(str::to_owned), releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV) - .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()), + .unwrap_or_else(|_| download::RELEASES_ENDPOINT.to_owned()), } + .with_engine_version(engine_version_override(None, |key| std::env::var(key).ok())) } + + /// Pins the Engine release to download. An explicit version always wins + /// over the build default, including a preview build's commit. + pub fn with_engine_version(mut self, version: Option) -> Self { + if let Some(version) = version.filter(|value| !value.trim().is_empty()) { + self.version = version.trim().to_owned(); + self.preview_ref = None; + } + self + } +} + +/// The Engine release to download instead of the build default, or `None` +/// to use the build default. +/// +/// Precedence is the explicit config value, then `RIVET_RUN_ENGINE_VERSION`, +/// then the deprecated `RIVETKIT_ENGINE_VERSION`. Empty values are ignored. +pub fn engine_version_override( + explicit: Option, + env: impl Fn(&str) -> Option, +) -> Option { + [ + explicit, + env(RUN_ENGINE_VERSION_ENV), + env(LEGACY_ENGINE_VERSION_ENV), + ] + .into_iter() + .flatten() + .map(|value| value.trim().to_owned()) + .find(|value| !value.is_empty()) } #[derive(Debug, PartialEq, Eq)] @@ -515,7 +558,7 @@ pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result Result Result return Ok(path.clone()); } - if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { + if let Some(path) = std::env::var_os(ENGINE_BINARY_PATH_ENV).map(PathBuf::from) { verify_binary_path(&path)?; return Ok(path); } let local_roots = local_engine_search_roots(); - let cached = cached_engine_path(&config.version)?; - match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? { + let cache_root = download::binary_cache_root()?; + match resolve_engine_binary_after_probe(config, false, &local_roots, &cache_root).await? { ResolvedEngine::Binary(path) => Ok(path), ResolvedEngine::Existing => { unreachable!("no-probe resolution never returns Existing") @@ -559,7 +602,7 @@ async fn resolve_engine_binary_after_probe( config: &EngineResolverConfig, existing_engine: bool, local_roots: &[PathBuf], - cached: PathBuf, + cache_root: &Path, ) -> Result { if existing_engine { return Ok(ResolvedEngine::Existing); @@ -569,19 +612,65 @@ async fn resolve_engine_binary_after_probe( return Ok(ResolvedEngine::Binary(path)); } - if cached.exists() { - return Ok(ResolvedEngine::Binary(cached)); - } - - if !config.auto_download { + let release = engine_release_binary(config)?; + // `RIVET_DISABLE_BINARY_DOWNLOAD` is reported by the downloader, which + // names the variable and the workarounds. + if !config.auto_download + && !download::download_disabled_by_env() + && !release.is_cached(cache_root) + { return Err(EngineProcessError::BinaryUnavailable { version: config.version.clone(), } .build()); } - download_engine_binary(config, &cached).await?; - Ok(ResolvedEngine::Binary(cached)) + let path = download::ensure_release_binary( + &release, + cache_root, + config.auto_download && !download::download_disabled_by_env(), + "no local Rivet Engine is running and no engine binary was provided", + ) + .await?; + Ok(ResolvedEngine::Binary(path)) +} + +/// Describes the release engine binary for this host and the configured +/// version, published at `{releases}/rivet/{version}/engine/`. +pub fn engine_release_binary(config: &EngineResolverConfig) -> Result { + let (platform, artifact) = engine_artifact().ok_or_else(|| { + download::unsupported_platform( + "Rivet Engine", + ENGINE_BINARY_PATH_ENV, + ENGINE_DOWNLOAD_ALTERNATIVE, + ) + })?; + // Preview builds download the Engine published for their own commit, and + // releases download the Engine published for their exact version. + let release = match &config.preview_ref { + Some(preview_ref) => { + download::validate_version("Rivet Engine", preview_ref)?; + preview_ref + } + None => { + download::validate_pinned_version("Rivet Engine", &config.version)?; + &config.version + } + }; + Ok(ReleaseBinary { + display_name: "Rivet Engine", + cache_name: "engine", + version: release.clone(), + platform: platform.to_owned(), + base_url: format!( + "{}/rivet/{release}/engine", + config.releases_endpoint.trim_end_matches('/'), + ), + artifact: artifact.to_owned(), + file_name: exe_name("rivet-engine"), + override_env: ENGINE_BINARY_PATH_ENV, + alternative: ENGINE_DOWNLOAD_ALTERNATIVE, + }) } fn verify_binary_path(path: &Path) -> Result { @@ -616,139 +705,17 @@ fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option { None } -fn cached_engine_path(version: &str) -> Result { - Ok(storage_root()? - .join("engine") - .join(version) - .join(engine_artifact_name())) -} - -async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> { - let artifact = engine_artifact_name(); - let base = config.releases_endpoint.trim_end_matches('/'); - let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version); - let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version); - let client = Client::builder() - .timeout(DOWNLOAD_TIMEOUT) - .build() - .context("build reqwest client for engine download")?; - - let manifest = fetch_text(&client, &manifest_url).await?; - let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| { - EngineProcessError::DownloadFailed { - url: manifest_url.clone(), - reason: format!("manifest does not contain `{artifact}`"), - } - .build() - })?; - - let bytes = fetch_bytes(&client, &artifact_url).await?; - let received = sha256_hex(&bytes); - if !received.eq_ignore_ascii_case(&expected) { - return Err(EngineProcessError::ChecksumMismatch { - artifact, - expected, - received, - } - .build()); - } - - let parent = destination - .parent() - .context("engine cache destination has no parent")?; - ensure_dir(parent)?; - std::fs::write(destination, bytes) - .with_context(|| format!("write engine binary `{}`", destination.display()))?; - make_executable(destination)?; - Ok(()) -} - -async fn fetch_text(client: &Client, url: &str) -> Result { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response.text().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -async fn fetch_bytes(client: &Client, url: &str) -> Result> { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { - manifest.lines().find_map(|line| { - let mut parts = line.split_whitespace(); - let checksum = parts.next()?; - let name = parts.next()?.trim_start_matches('*'); - (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned()) - }) -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write; - let _ = write!(&mut out, "{byte:02x}"); +/// Returns the platform label and release artifact name of the engine binary +/// for this host, or `None` when no release binary is published for it. +fn engine_artifact() -> Option<(&'static str, &'static str)> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some(("linux-x64", "rivet-engine-x86_64-unknown-linux-musl")), + ("linux", "aarch64") => Some(("linux-arm64", "rivet-engine-aarch64-unknown-linux-musl")), + ("macos", "x86_64") => Some(("darwin-x64", "rivet-engine-x86_64-apple-darwin")), + ("macos", "aarch64") => Some(("darwin-arm64", "rivet-engine-aarch64-apple-darwin")), + ("windows", "x86_64") => Some(("win32-x64", "rivet-engine-x86_64-pc-windows-gnu.exe")), + _ => None, } - out -} - -fn engine_artifact_name() -> String { - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - other => other, - }; - let target = match std::env::consts::OS { - "linux" => format!("{arch}-unknown-linux-musl"), - "macos" => format!("{arch}-apple-darwin"), - "windows" => format!("{arch}-pc-windows-gnu.exe"), - other => format!("{arch}-{other}"), - }; - format!("rivet-engine-{target}") } fn exe_name(base: &str) -> String { @@ -759,24 +726,6 @@ fn exe_name(base: &str) -> String { } } -fn make_executable(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(path) - .with_context(|| format!("read metadata for `{}`", path.display()))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions) - .with_context(|| format!("mark `{}` executable", path.display()))?; - } - #[cfg(not(unix))] - { - let _ = path; - } - Ok(()) -} - impl Drop for EngineProcessManager { fn drop(&mut self) { if let Some(handle) = self.watcher.take() { @@ -997,243 +946,5 @@ fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - use super::*; - - fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { - EngineResolverConfig { - endpoint: "http://127.0.0.1:1".to_owned(), - explicit_binary_path: None, - bind_host: None, - bind_port: None, - public_url: None, - auto_download, - version: "test-version".to_owned(), - releases_endpoint, - } - } - - fn stamp_with_bind(bind_host: &str) -> EngineRuntimeStamp { - EngineRuntimeStamp { - pid: 1, - endpoint: "http://127.0.0.1:6420".to_owned(), - bind_host: bind_host.to_owned(), - public_url: None, - } - } - - #[test] - fn binds_loopback_only_detects_loopback_addresses() { - assert!(stamp_with_bind("127.0.0.1").binds_loopback_only()); - assert!(stamp_with_bind("localhost").binds_loopback_only()); - assert!(stamp_with_bind("::1").binds_loopback_only()); - } - - #[test] - fn binds_loopback_only_false_for_reachable_addresses() { - assert!(!stamp_with_bind("0.0.0.0").binds_loopback_only()); - assert!(!stamp_with_bind("::").binds_loopback_only()); - assert!(!stamp_with_bind("192.168.1.5").binds_loopback_only()); - } - - #[test] - fn resolve_bind_host_prefers_override_then_endpoint() { - let mut config = test_config(String::new(), false); - config.endpoint = "http://127.0.0.1:6420".to_owned(); - assert_eq!(resolve_bind_host(&config).unwrap(), "127.0.0.1"); - config.bind_host = Some("0.0.0.0".to_owned()); - assert_eq!(resolve_bind_host(&config).unwrap(), "0.0.0.0"); - } - - #[test] - fn engine_env_uses_development_defaults() { - let env = engine_env(&test_config(String::new(), false)).expect("build engine env"); - let env = env.into_iter().collect::>(); - - assert_eq!(env["RIVET__PEGBOARD__RETRY_RESET_DURATION"], "100"); - assert_eq!(env["RIVET__PEGBOARD__BASE_RETRY_TIMEOUT"], "100"); - assert_eq!(env["RIVET__PEGBOARD__RESCHEDULE_BACKOFF_MAX_EXPONENT"], "1"); - assert_eq!(env["RIVET__PEGBOARD__RUNNER_ELIGIBLE_THRESHOLD"], "5000"); - assert_eq!(env["RIVET__PEGBOARD__RUNNER_LOST_THRESHOLD"], "7000"); - assert_eq!(env["RIVET__PEGBOARD__ENVOY_ELIGIBLE_THRESHOLD"], "5000"); - assert_eq!(env["RIVET__PEGBOARD__ENVOY_LOST_THRESHOLD"], "7000"); - assert_eq!(env["RIVET__PEGBOARD__MIN_METADATA_POLL_INTERVAL"], "1000"); - assert_eq!(env["RIVET__AUTH__ADMIN_TOKEN"], "default"); - assert_eq!(env["RIVET__FEATURES__GUARD_GATEWAY_V3__MODE"], "on"); - assert_eq!(env["RIVET__FEATURES__GUARD_GATEWAY_V3__PERCENTAGE"], "100"); - assert_eq!(env["RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION"], "1"); - assert_eq!(env["RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION"], "1"); - assert_eq!(env["RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION"], "2"); - } - - #[cfg(unix)] - #[test] - fn pid_is_alive_true_for_self_false_for_missing() { - assert!(pid_is_alive(std::process::id())); - // PID 0 targets the process group on unix, so use a very high, unused PID. - assert!(!pid_is_alive(0x7FFF_FFFF)); - } - - #[tokio::test] - async fn resolver_prefers_existing_engine_before_filesystem_paths() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - true, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Existing); - } - - #[tokio::test] - async fn resolver_prefers_local_binary_before_cached_binary() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(local)); - } - - #[tokio::test] - async fn resolver_reuses_cached_binary_without_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached.clone(), - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(cached)); - } - - #[tokio::test] - async fn resolver_reports_actionable_error_without_binary_or_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let error = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached, - ) - .await - .expect_err("missing binary should fail"); - let message = error.to_string(); - - assert!(message.contains("No usable engine binary was found")); - assert!(message.contains("Build `rivet-engine`")); - assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); - } - - #[tokio::test] - async fn resolver_download_checks_manifest_checksum() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - let artifact = engine_artifact_name(); - let expected = sha256_hex(b"different bytes"); - let manifest = format!("{expected} {artifact}\n"); - let releases_endpoint = spawn_download_server(HashMap::from([ - ( - format!("/rivet/test-version/engine/SHA256SUMS"), - manifest.into_bytes(), - ), - ( - format!("/rivet/test-version/engine/{artifact}"), - b"actual bytes".to_vec(), - ), - ])) - .await; - - let error = resolve_engine_binary_after_probe( - &test_config(releases_endpoint, true), - false, - &[], - cached, - ) - .await - .expect_err("checksum mismatch should fail"); - - assert!( - error - .to_string() - .contains("Engine binary checksum mismatch") - ); - } - - async fn spawn_download_server(routes: HashMap>) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind download server"); - let addr = listener.local_addr().expect("download server address"); - tokio::spawn(async move { - for _ in 0..routes.len() { - let (mut socket, _) = listener.accept().await.expect("accept download request"); - let mut buffer = [0_u8; 2048]; - let n = socket - .read(&mut buffer) - .await - .expect("read download request"); - let request = String::from_utf8_lossy(&buffer[..n]); - let path = request - .split_whitespace() - .nth(1) - .expect("request path") - .to_owned(); - let body = routes.get(&path).expect("route body"); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", - body.len() - ); - socket - .write_all(header.as_bytes()) - .await - .expect("write response header"); - socket.write_all(body).await.expect("write response body"); - } - }); - format!("http://{addr}") - } -} +#[path = "../tests/unit/lib.rs"] +mod tests; diff --git a/rivetkit-rust/packages/engine-process/tests/unit/download.rs b/rivetkit-rust/packages/engine-process/tests/unit/download.rs new file mode 100644 index 0000000000..5a8869eeb9 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/tests/unit/download.rs @@ -0,0 +1,614 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use super::*; + +const ARTIFACT: &str = "rivet-test-x86_64"; +const BODY: &[u8] = b"#!/bin/sh\necho test binary\n"; + +#[derive(Clone)] +enum Route { + Ok(Vec), + /// Advertises the full length but closes the connection after `sent` bytes. + Truncated { body: Vec, sent: usize }, + /// Returns `status` for the first `failures` requests, then `body`. + FailThenOk { + status: u16, + failures: usize, + seen: Arc, + body: Vec, + }, +} + +struct TestServer { + base_url: String, + artifact_requests: Arc, +} + +async fn spawn_server(routes: HashMap) -> TestServer { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind download server"); + let addr = listener.local_addr().expect("download server address"); + let routes = Arc::new(routes); + let artifact_requests = Arc::new(AtomicUsize::new(0)); + let counter = artifact_requests.clone(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let routes = routes.clone(); + let counter = counter.clone(); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let n = socket.read(&mut buffer).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buffer[..n]); + let path = request.split_whitespace().nth(1).unwrap_or("/").to_owned(); + if path.ends_with(ARTIFACT) { + counter.fetch_add(1, Ordering::SeqCst); + } + let route = match routes.get(&path) { + Some(Route::FailThenOk { + status, + failures, + seen, + body, + }) => { + if seen.fetch_add(1, Ordering::SeqCst) < *failures { + let response = format!( + "HTTP/1.1 {status} Error\r\nretry-after: 0\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + Some(Route::Ok(body.clone())) + } + other => other.cloned(), + }; + match route { + Some(Route::FailThenOk { .. }) => {} + Some(Route::Ok(body)) => { + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + let _ = socket.write_all(header.as_bytes()).await; + // Write in small chunks so concurrent clients overlap. + for chunk in body.chunks(8) { + let _ = socket.write_all(chunk).await; + tokio::task::yield_now().await; + } + } + Some(Route::Truncated { body, sent }) => { + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + let _ = socket.write_all(header.as_bytes()).await; + let _ = socket.write_all(&body[..sent]).await; + } + None => { + let _ = socket + .write_all( + b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await; + } + } + let _ = socket.shutdown().await; + }); + } + }); + TestServer { + base_url: format!("http://{addr}/rivet/1.2.3/test"), + artifact_requests, + } +} + +fn manifest_for(body: &[u8]) -> Vec { + format!( + "{} other-artifact\n{} {ARTIFACT}\n", + sha256_hex(b"other"), + sha256_hex(body) + ) + .into_bytes() +} + +fn routes(manifest: Vec, artifact: Route) -> HashMap { + HashMap::from([ + ("/rivet/1.2.3/test/SHA256SUMS".to_owned(), Route::Ok(manifest)), + (format!("/rivet/1.2.3/test/{ARTIFACT}"), artifact), + ]) +} + +fn release(base_url: &str) -> ReleaseBinary { + ReleaseBinary { + display_name: "Rivet Test", + cache_name: "test", + version: "1.2.3".to_owned(), + platform: "linux-x64".to_owned(), + base_url: base_url.to_owned(), + artifact: ARTIFACT.to_owned(), + file_name: "rivet-test".to_owned(), + override_env: "RIVET_TEST_BINARY", + alternative: "skip the test.", + } +} + +fn error_code(error: &anyhow::Error) -> String { + let error = rivet_error::RivetError::extract(error); + format!("{}.{}", error.group(), error.code()) +} + +fn cache_dir_entries(cache: &Path) -> Vec { + let dir = release("http://unused").cache_path(cache); + let Some(parent) = dir.parent() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(parent) else { + return Vec::new(); + }; + let mut names = entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| !name.ends_with(".lock") && !name.ends_with(".sha256")) + .collect::>(); + names.sort(); + names +} + +#[test] +fn parses_sha256sums_manifest() { + let manifest = format!( + "{} a\n{} *b\nnot-a-checksum c\n", + sha256_hex(b"a"), + sha256_hex(b"b") + ); + assert_eq!( + checksum_for_artifact(&manifest, "b"), + Some(sha256_hex(b"b")) + ); + assert_eq!(checksum_for_artifact(&manifest, "c"), None); + assert_eq!(checksum_for_artifact(&manifest, "missing"), None); +} + +#[tokio::test] +async fn downloads_verifies_and_caches() { + let server = spawn_server(routes(manifest_for(BODY), Route::Ok(BODY.to_vec()))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let path = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect("download binary"); + assert_eq!(path, release.cache_path(cache.path())); + assert_eq!(std::fs::read(&path).expect("read binary"), BODY); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).expect("metadata").permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "binary must be executable"); + } + + // A second resolution uses the cache, even with downloads disabled. + let again = ensure_release_binary(&release, cache.path(), false, "test") + .await + .expect("reuse cached binary"); + assert_eq!(again, path); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 1); + assert_eq!(cache_dir_entries(cache.path()), vec!["rivet-test".to_owned()]); +} + +#[tokio::test] +async fn rejects_checksum_mismatch_without_caching() { + let server = spawn_server(routes( + manifest_for(b"different bytes"), + Route::Ok(BODY.to_vec()), + )) + .await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let error = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect_err("checksum mismatch should fail"); + assert_eq!(error_code(&error), "binary_download.checksum_mismatch"); + assert!(error.to_string().contains("RIVET_TEST_BINARY")); + assert!(!release.cache_path(cache.path()).exists()); + assert!(cache_dir_entries(cache.path()).is_empty()); +} + +#[tokio::test] +async fn rejects_artifact_missing_from_manifest() { + let manifest = format!("{} other-artifact\n", sha256_hex(b"other")).into_bytes(); + let server = spawn_server(routes(manifest, Route::Ok(BODY.to_vec()))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + + let error = ensure_release_binary(&release(&server.base_url), cache.path(), true, "test") + .await + .expect_err("missing checksum should fail"); + assert_eq!(error_code(&error), "binary_download.checksum_missing"); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn reports_http_404_with_url() { + let server = spawn_server(HashMap::new()).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let error = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect_err("404 should fail"); + assert_eq!(error_code(&error), "binary_download.http_status"); + let message = error.to_string(); + assert!(message.contains("HTTP 404"), "{message}"); + assert!(message.contains(&release.manifest_url()), "{message}"); +} + +#[tokio::test] +async fn discards_partial_download() { + let body = vec![7_u8; 4096]; + let server = spawn_server(routes( + manifest_for(&body), + Route::Truncated { body, sent: 1000 }, + )) + .await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let error = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect_err("partial download should fail"); + assert_eq!(error_code(&error), "binary_download.incomplete"); + assert!(!release.cache_path(cache.path()).exists()); + assert!(cache_dir_entries(cache.path()).is_empty()); +} + +#[tokio::test] +async fn refuses_to_download_when_disabled() { + let cache = tempfile::tempdir().expect("create cache dir"); + let error = ensure_release_binary(&release("http://127.0.0.1:1"), cache.path(), false, "test") + .await + .expect_err("disabled download should fail"); + assert_eq!(error_code(&error), "binary_download.disabled"); + let message = error.to_string(); + assert!(message.contains(DISABLE_DOWNLOAD_ENV), "{message}"); + assert!(message.contains("RIVET_TEST_BINARY"), "{message}"); +} + +#[tokio::test] +async fn reports_unreachable_server() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + let cache = tempfile::tempdir().expect("create cache dir"); + + let error = ensure_release_binary( + &release(&format!("http://{addr}/rivet/1.2.3/test")), + cache.path(), + true, + "test", + ) + .await + .expect_err("unreachable server should fail"); + assert_eq!(error_code(&error), "binary_download.request_failed"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_first_run_downloads_once() { + let body = vec![42_u8; 256 * 1024]; + let server = spawn_server(routes(manifest_for(&body), Route::Ok(body.clone()))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let mut tasks = Vec::new(); + for _ in 0..8 { + let release = release.clone(); + let cache = cache.path().to_path_buf(); + tasks.push(tokio::spawn(async move { + ensure_release_binary(&release, &cache, true, "test").await + })); + } + for task in tasks { + let path = task.await.expect("join").expect("download binary"); + assert_eq!(std::fs::read(&path).expect("read binary"), body); + } + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 1); + assert_eq!(cache_dir_entries(cache.path()), vec!["rivet-test".to_owned()]); +} + +#[test] +fn removes_stale_partial_downloads() { + let dir = tempfile::tempdir().expect("create dir"); + std::fs::write(dir.path().join(".rivet-test.123.download"), b"partial").expect("write"); + std::fs::write(dir.path().join("rivet-test"), b"keep").expect("write"); + remove_stale_downloads(dir.path(), "rivet-test"); + assert!(!dir.path().join(".rivet-test.123.download").exists()); + assert!(dir.path().join("rivet-test").exists()); +} + +#[tokio::test] +async fn redownloads_truncated_cached_binary() { + let server = spawn_server(routes(manifest_for(BODY), Route::Ok(BODY.to_vec()))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + let path = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect("download binary"); + + std::fs::write(&path, b"").expect("truncate cached binary"); + assert!(!release.is_cached(cache.path())); + ensure_release_binary(&release, cache.path(), false, "test") + .await + .expect_err("corrupt cache must not be used without a download"); + + let again = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect("redownload binary"); + assert_eq!(std::fs::read(&again).expect("read binary"), BODY); + assert!(release.is_cached(cache.path())); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn ignores_cached_binary_without_checksum_record() { + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release("http://127.0.0.1:1"); + let path = release.cache_path(cache.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + std::fs::write(&path, BODY).expect("write binary"); + + assert!(!release.is_cached(cache.path())); + let error = ensure_release_binary(&release, cache.path(), false, "test") + .await + .expect_err("unverified binary should not be used"); + assert_eq!(error_code(&error), "binary_download.disabled"); +} + +#[tokio::test] +async fn rejects_versions_that_escape_the_cache() { + let cache = tempfile::tempdir().expect("create cache dir"); + for version in ["..", "../1.2.3", "1.2.3/../../x", "", ".hidden", "1.2.3\\x"] { + let mut release = release("http://127.0.0.1:1"); + release.version = version.to_owned(); + let error = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect_err("invalid version should fail"); + assert_eq!(error_code(&error), "binary_download.invalid_version", "{version}"); + } + validate_version("Rivet Test", "2.3.30-rc.1+build_5").expect("valid version"); +} + +#[tokio::test] +async fn lock_wait_times_out_with_lock_path() { + let dir = tempfile::tempdir().expect("create dir"); + let lock_path = dir.path().join("rivet-test.lock"); + let release = release("http://127.0.0.1:1"); + let held = acquire_lock(&release, &lock_path, Duration::from_secs(1)) + .await + .expect("acquire first lock"); + + let error = acquire_lock(&release, &lock_path, Duration::from_millis(100)) + .await + .err() + .expect("contended lock should time out"); + assert_eq!(error_code(&error), "binary_download.lock_timeout"); + assert!(error.to_string().contains(&lock_path.display().to_string())); + + drop(held); + acquire_lock(&release, &lock_path, Duration::from_secs(5)) + .await + .expect("lock is free after release"); +} + +#[tokio::test] +async fn cancelled_download_removes_temp_file() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let body = vec![1_u8; 64]; + let manifest = manifest_for(&body); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let manifest = manifest.clone(); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let n = socket.read(&mut buffer).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buffer[..n]).into_owned(); + if request.contains("SHA256SUMS") { + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + manifest.len() + ); + let _ = socket.write_all(header.as_bytes()).await; + let _ = socket.write_all(&manifest).await; + return; + } + // Send part of the artifact, then stall so the download stays open. + let _ = socket + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 64\r\nconnection: close\r\n\r\n") + .await; + let _ = socket.write_all(&[1_u8; 16]).await; + std::future::pending::<()>().await; + }); + } + }); + + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&format!("http://{addr}/rivet/1.2.3/test")); + let cache_root = cache.path().to_path_buf(); + let task = { + let release = release.clone(); + tokio::spawn(async move { ensure_release_binary(&release, &cache_root, true, "test").await }) + }; + let directory = release + .cache_path(cache.path()) + .parent() + .expect("parent") + .to_path_buf(); + // The download runs in another task and signals nothing, so poll for its temp file. + tokio::time::timeout(Duration::from_secs(10), async { + while !std::fs::read_dir(&directory) + .map(|entries| { + entries + .flatten() + .any(|entry| entry.file_name().to_string_lossy().ends_with(".download")) + }) + .unwrap_or(false) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("download should create a temp file"); + task.abort(); + let _ = task.await; + + assert!(cache_dir_entries(cache.path()).is_empty()); +} + +fn fail_then_ok(status: u16, failures: usize, body: &[u8]) -> Route { + Route::FailThenOk { + status, + failures, + seen: Arc::new(AtomicUsize::new(0)), + body: body.to_vec(), + } +} + +#[tokio::test] +async fn retries_server_errors_then_succeeds() { + let server = spawn_server(routes(manifest_for(BODY), fail_then_ok(503, 2, BODY))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let path = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect("download after retries"); + assert_eq!(std::fs::read(&path).expect("read binary"), BODY); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn gives_up_after_three_retries() { + let server = spawn_server(routes(manifest_for(BODY), fail_then_ok(502, 100, BODY))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let release = release(&server.base_url); + + let error = ensure_release_binary(&release, cache.path(), true, "test") + .await + .expect_err("persistent server error should fail"); + assert_eq!(error_code(&error), "binary_download.http_status"); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 4); + assert!(cache_dir_entries(cache.path()).is_empty()); +} + +#[tokio::test] +async fn does_not_retry_missing_release_or_bad_checksum() { + let server = spawn_server(routes(manifest_for(BODY), fail_then_ok(404, 100, BODY))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + let error = ensure_release_binary(&release(&server.base_url), cache.path(), true, "test") + .await + .expect_err("404 should fail"); + assert_eq!(error_code(&error), "binary_download.http_status"); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 1); + + let server = spawn_server(routes( + manifest_for(b"different bytes"), + Route::Ok(BODY.to_vec()), + )) + .await; + let error = ensure_release_binary(&release(&server.base_url), cache.path(), true, "test") + .await + .expect_err("checksum mismatch should fail"); + assert_eq!(error_code(&error), "binary_download.checksum_mismatch"); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 1); +} + +#[test] +fn classifies_transient_errors() { + let binary = release("http://127.0.0.1:1"); + let status = |status: u16| { + check_status_code(&binary, "http://x", StatusCode::from_u16(status).expect("status")) + .expect_err("error status") + }; + assert!(is_retryable(&status(500))); + assert!(is_retryable(&status(503))); + assert!(is_retryable(&status(429))); + assert!(!is_retryable(&status(404))); + assert!(!is_retryable(&status(403))); + assert!(is_retryable(&request_error(&binary, "http://x", "connection refused"))); + assert!(is_retryable(&incomplete_or_request_error( + &binary, + "http://x", + 1, + Some(2), + "reset".to_owned() + ))); + assert!(!is_retryable(&io_error(&binary, "write", Path::new("/x"), "disk full"))); +} + +#[test] +fn detects_filesystems_without_locking() { + assert!(lock_unsupported(&std::io::Error::from(std::io::ErrorKind::Unsupported))); + #[cfg(unix)] + { + assert!(lock_unsupported(&std::io::Error::from_raw_os_error(libc::ENOLCK))); + assert!(lock_unsupported(&std::io::Error::from_raw_os_error(libc::EOPNOTSUPP))); + assert!(!lock_unsupported(&std::io::Error::from_raw_os_error(libc::EACCES))); + } +} + +#[test] +fn rejects_unpinned_versions() { + for version in ["latest", "main", "stable"] { + let error = validate_pinned_version("Rivet Test", version).expect_err("alias"); + assert_eq!(error_code(&error), "binary_download.unpinned_version", "{version}"); + } + validate_pinned_version("Rivet Test", "2.3.30-rc.1").expect("pinned version"); +} + +#[tokio::test] +async fn retries_rate_limited_downloads_then_succeeds() { + let server = spawn_server(routes(manifest_for(BODY), fail_then_ok(429, 2, BODY))).await; + let cache = tempfile::tempdir().expect("create cache dir"); + + let path = ensure_release_binary(&release(&server.base_url), cache.path(), true, "test") + .await + .expect("download after rate limiting"); + assert_eq!(std::fs::read(&path).expect("read binary"), BODY); + assert_eq!(server.artifact_requests.load(Ordering::SeqCst), 3); +} + +#[test] +fn honors_retry_after_up_to_the_cap() { + let headers = |retry_after: &str| { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::RETRY_AFTER, + retry_after.parse().expect("header value"), + ); + headers + }; + let limited = StatusCode::TOO_MANY_REQUESTS; + + assert_eq!(retry_after(limited, &headers("0")), Some(Duration::ZERO)); + assert_eq!(retry_after(limited, &headers("3600")), Some(MAX_RETRY_AFTER)); + // The HTTP-date form falls back to exponential backoff. + assert_eq!(retry_after(limited, &headers("Wed, 21 Oct 2026 07:28:00 GMT")), None); + assert_eq!(retry_after(limited, &reqwest::header::HeaderMap::new()), None); + assert_eq!( + retry_after(StatusCode::SERVICE_UNAVAILABLE, &headers("5")), + None, + "only rate-limited responses honor Retry-After" + ); +} diff --git a/rivetkit-rust/packages/engine-process/tests/unit/lib.rs b/rivetkit-rust/packages/engine-process/tests/unit/lib.rs new file mode 100644 index 0000000000..8685277203 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/tests/unit/lib.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; + +use super::*; + +fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { + EngineResolverConfig { + endpoint: "http://127.0.0.1:1".to_owned(), + explicit_binary_path: None, + bind_host: None, + bind_port: None, + public_url: None, + auto_download, + version: "1.2.3-test".to_owned(), + preview_ref: None, + releases_endpoint, + } +} + +fn stamp_with_bind(bind_host: &str) -> EngineRuntimeStamp { + EngineRuntimeStamp { + pid: 1, + endpoint: "http://127.0.0.1:6420".to_owned(), + bind_host: bind_host.to_owned(), + public_url: None, + } +} + +fn write_cached_engine(cache_root: &Path) -> PathBuf { + let release = engine_release_binary(&test_config("http://127.0.0.1:1".to_owned(), false)) + .expect("engine release for host"); + let cached = release.cache_path(cache_root); + std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); + std::fs::write(&cached, b"cached").expect("write cached binary"); + download::write_checksum_record( + &download::checksum_record_path(&cached), + &download::sha256_hex(b"cached"), + 6, + ) + .expect("write checksum record"); + cached +} + +#[test] +fn binds_loopback_only_detects_loopback_addresses() { + assert!(stamp_with_bind("127.0.0.1").binds_loopback_only()); + assert!(stamp_with_bind("localhost").binds_loopback_only()); + assert!(stamp_with_bind("::1").binds_loopback_only()); +} + +#[test] +fn binds_loopback_only_false_for_reachable_addresses() { + assert!(!stamp_with_bind("0.0.0.0").binds_loopback_only()); + assert!(!stamp_with_bind("::").binds_loopback_only()); + assert!(!stamp_with_bind("192.168.1.5").binds_loopback_only()); +} + +#[test] +fn resolve_bind_host_prefers_override_then_endpoint() { + let mut config = test_config(String::new(), false); + config.endpoint = "http://127.0.0.1:6420".to_owned(); + assert_eq!(resolve_bind_host(&config).unwrap(), "127.0.0.1"); + config.bind_host = Some("0.0.0.0".to_owned()); + assert_eq!(resolve_bind_host(&config).unwrap(), "0.0.0.0"); +} + +#[test] +fn engine_env_uses_development_defaults() { + let env = engine_env(&test_config(String::new(), false)).expect("build engine env"); + let env = env.into_iter().collect::>(); + + assert_eq!(env["RIVET__PEGBOARD__RETRY_RESET_DURATION"], "100"); + assert_eq!(env["RIVET__PEGBOARD__BASE_RETRY_TIMEOUT"], "100"); + assert_eq!(env["RIVET__PEGBOARD__RESCHEDULE_BACKOFF_MAX_EXPONENT"], "1"); + assert_eq!(env["RIVET__PEGBOARD__RUNNER_ELIGIBLE_THRESHOLD"], "5000"); + assert_eq!(env["RIVET__PEGBOARD__RUNNER_LOST_THRESHOLD"], "7000"); + assert_eq!(env["RIVET__PEGBOARD__ENVOY_ELIGIBLE_THRESHOLD"], "5000"); + assert_eq!(env["RIVET__PEGBOARD__ENVOY_LOST_THRESHOLD"], "7000"); + assert_eq!(env["RIVET__PEGBOARD__MIN_METADATA_POLL_INTERVAL"], "1000"); + assert_eq!(env["RIVET__AUTH__ADMIN_TOKEN"], "default"); + assert_eq!(env["RIVET__FEATURES__GUARD_GATEWAY_V3__MODE"], "on"); + assert_eq!(env["RIVET__FEATURES__GUARD_GATEWAY_V3__PERCENTAGE"], "100"); + assert_eq!(env["RIVET__RUNTIME__WORKER_SHUTDOWN_DURATION"], "1"); + assert_eq!(env["RIVET__RUNTIME__GUARD_SHUTDOWN_DURATION"], "1"); + assert_eq!(env["RIVET__RUNTIME__FORCE_SHUTDOWN_DURATION"], "2"); +} + +#[cfg(unix)] +#[test] +fn pid_is_alive_true_for_self_false_for_missing() { + assert!(pid_is_alive(std::process::id())); + // PID 0 targets the process group on unix, so use a very high, unused PID. + assert!(!pid_is_alive(0x7FFF_FFFF)); +} + +#[test] +fn engine_release_binary_uses_versioned_release_path() { + let release = engine_release_binary(&test_config( + "https://releases.rivet.dev/".to_owned(), + true, + )) + .expect("engine release for host"); + assert_eq!( + release.base_url, + "https://releases.rivet.dev/rivet/1.2.3-test/engine" + ); + assert!(release.artifact.starts_with("rivet-engine-")); + assert_eq!( + release.manifest_url(), + "https://releases.rivet.dev/rivet/1.2.3-test/engine/SHA256SUMS" + ); +} + +#[tokio::test] +async fn resolver_prefers_existing_engine_before_filesystem_paths() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + true, + &[temp.path().to_path_buf()], + &temp.path().join("cache"), + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Existing); +} + +#[tokio::test] +async fn resolver_prefers_local_binary_before_cached_binary() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + let cache_root = temp.path().join("cache"); + write_cached_engine(&cache_root); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[temp.path().to_path_buf()], + &cache_root, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(local)); +} + +#[tokio::test] +async fn resolver_reuses_cached_binary_without_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cache_root = temp.path().join("cache"); + let cached = write_cached_engine(&cache_root); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + &cache_root, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(cached)); +} + +#[tokio::test] +async fn resolver_ignores_cached_binary_without_checksum_record() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cache_root = temp.path().join("cache"); + let cached = write_cached_engine(&cache_root); + std::fs::remove_file(download::checksum_record_path(&cached)).expect("remove record"); + + let error = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + &cache_root, + ) + .await + .expect_err("unverified cache entry should not be used"); + assert!(error.to_string().contains("No usable engine binary was found")); +} + +#[test] +fn engine_release_rejects_path_traversal_version() { + let mut config = test_config("http://127.0.0.1:1".to_owned(), true); + config.version = "../../etc".to_owned(); + let error = engine_release_binary(&config).expect_err("traversal version should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.group(), "binary_download"); + assert_eq!(error.code(), "invalid_version"); +} + +#[tokio::test] +async fn resolver_reports_actionable_error_without_binary_or_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + + let error = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + &temp.path().join("cache"), + ) + .await + .expect_err("missing binary should fail"); + let message = error.to_string(); + + assert!(message.contains("No usable engine binary was found")); + assert!(message.contains("Build `rivet-engine`")); + assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); +} + +#[test] +fn engine_release_rejects_unpinned_versions() { + for version in ["latest", "main", "next", "nightly"] { + let config = test_config("http://127.0.0.1:1".to_owned(), true) + .with_engine_version(Some(version.to_owned())); + let error = engine_release_binary(&config).expect_err("alias should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.code(), "unpinned_version", "{version}"); + assert!(error.message().contains("`latest`"), "{}", error.message()); + } +} + +#[test] +fn preview_builds_download_their_commit_engine() { + let mut config = test_config("https://releases.rivet.dev/".to_owned(), true); + config.preview_ref = Some("abc1234".to_owned()); + let release = engine_release_binary(&config).expect("preview release"); + assert_eq!(release.base_url, "https://releases.rivet.dev/rivet/abc1234/engine"); + assert_eq!(release.version, "abc1234"); + + // An explicit version overrides the preview commit. + let release = engine_release_binary(&config.with_engine_version(Some("2.3.30".to_owned()))) + .expect("pinned release"); + assert_eq!(release.base_url, "https://releases.rivet.dev/rivet/2.3.30/engine"); +} + +#[test] +fn missing_explicit_engine_binary_explains_how_to_fix_it() { + let error = verify_binary_path(Path::new("/nonexistent/rivet-engine")) + .expect_err("missing binary should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.group(), "engine"); + assert_eq!(error.code(), "binary_not_found"); + assert!( + error.message().contains("RIVET_ENGINE_BINARY_PATH"), + "resolution should name the setting to fix: {}", + error.message() + ); +} + +#[test] +fn engine_version_prefers_explicit_then_run_env_then_legacy_env() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |key: &str| { + pairs + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| (*value).to_owned()) + } + }; + let both = &[ + ("RIVET_RUN_ENGINE_VERSION", "2.3.29"), + ("RIVETKIT_ENGINE_VERSION", "2.3.28"), + ]; + + assert_eq!( + engine_version_override(Some("2.3.30".to_owned()), env(both)).as_deref(), + Some("2.3.30") + ); + assert_eq!(engine_version_override(None, env(both)).as_deref(), Some("2.3.29")); + assert_eq!( + engine_version_override(None, env(&[("RIVETKIT_ENGINE_VERSION", "2.3.28")])).as_deref(), + Some("2.3.28") + ); + assert_eq!( + engine_version_override(Some(" ".to_owned()), env(&[("RIVET_RUN_ENGINE_VERSION", "")])) + .as_deref(), + None, + "empty values fall through to the build default" + ); +} + +#[test] +fn auto_download_disable_env_beats_explicit_config() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |key: &str| { + pairs + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| (*value).to_owned()) + } + }; + assert!(download::resolve_auto_download(None, true, env(&[]))); + assert!(!download::resolve_auto_download(Some(false), true, env(&[]))); + assert!(!download::resolve_auto_download( + None, + true, + env(&[("RIVETKIT_ENGINE_AUTO_DOWNLOAD", "0")]) + )); + assert!(download::resolve_auto_download( + Some(true), + true, + env(&[("RIVETKIT_ENGINE_AUTO_DOWNLOAD", "0")]) + )); + assert!(!download::resolve_auto_download( + Some(true), + true, + env(&[("RIVET_DISABLE_BINARY_DOWNLOAD", "1")]) + )); +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/development_process.rs b/rivetkit-rust/packages/rivetkit-core/src/development_process.rs index 34e7d18b1a..6d230ff582 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/development_process.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/development_process.rs @@ -1,8 +1,95 @@ +use std::path::PathBuf; + use anyhow::Result; +use rivetkit_engine_process::download; use crate::engine_process::{EngineProcessManager, EngineResolverConfig}; -use crate::registry::ServeConfig; -use crate::services_process::{SERVICES_POOL_NAME, ServicesProcessConfig, ServicesProcessManager}; +use crate::registry::{ServeConfig, parse_engine_port}; +use crate::services_process::{ + SERVICES_POOL_NAME, ServicesProcessConfig, ServicesProcessManager, skip_unsupported_services, +}; + +const RUN_SERVICES_ENV: &str = "RIVET_RUN_SERVICES"; +const RUN_ENGINE_HOST_ENV: &str = "RIVET_RUN_ENGINE_HOST"; +const RUN_ENGINE_PORT_ENV: &str = "RIVET_RUN_ENGINE_PORT"; +const ENGINE_BINARY_PATH_ENV: &str = "RIVET_ENGINE_BINARY_PATH"; +const SERVICES_BINARY_ENV: &str = "RIVET_SERVICES_BINARY"; + +/// Local development settings after applying, in order, the explicit +/// `ServeConfig` value, the environment, and the default. +/// +/// Every host (TypeScript through NAPI and Rust through `ServeConfig`) resolves +/// through this one function so local Engine and Services startup behaves the +/// same everywhere. Hosts pass only values the user set in code. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalProcessSettings { + pub(crate) engine_binary_path: Option, + pub(crate) engine_host: Option, + pub(crate) engine_port: Option, + pub(crate) engine_version: Option, + pub(crate) auto_download: bool, + pub(crate) start_services: bool, + /// Whether Services was explicitly requested in config or the environment. + /// When false, a platform with no Services build runs without Services. + pub(crate) services_required: bool, + pub(crate) services_binary_path: Option, +} + +impl LocalProcessSettings { + pub(crate) fn resolve(config: &ServeConfig) -> Result { + Self::resolve_with(config, |key| std::env::var(key).ok()) + } + + pub(crate) fn resolve_with( + config: &ServeConfig, + env: impl Fn(&str) -> Option, + ) -> Result { + let env_value = |key: &str| env(key).filter(|value| !value.trim().is_empty()); + + let engine_port = match config.engine_port { + Some(port) => Some(port), + None => env_value(RUN_ENGINE_PORT_ENV) + .map(|value| parse_engine_port(&value)) + .transpose()?, + }; + + // Services defaults on because this only runs when RivetKit manages a + // local Engine. An explicit choice in config or the environment makes a + // missing Services build an error instead of a warning. + let (start_services, services_required) = + match (config.start_services, env_value(RUN_SERVICES_ENV)) { + (Some(start), _) => (start, start), + (None, Some(value)) => { + let start = download::is_truthy(&value); + (start, start) + } + (None, None) => (true, false), + }; + + Ok(Self { + engine_binary_path: config + .engine_binary_path + .clone() + .or_else(|| env_value(ENGINE_BINARY_PATH_ENV).map(PathBuf::from)), + engine_host: config + .engine_host + .clone() + .or_else(|| env_value(RUN_ENGINE_HOST_ENV)), + engine_port, + engine_version: rivetkit_engine_process::engine_version_override( + config.engine_version.clone(), + &env, + ), + auto_download: download::resolve_auto_download(config.engine_auto_download, true, &env), + start_services, + services_required, + services_binary_path: config + .services_binary_path + .clone() + .or_else(|| env_value(SERVICES_BINARY_ENV).map(PathBuf::from)), + }) + } +} /// Owns the processes RivetKit starts for a local development environment. /// @@ -20,27 +107,32 @@ pub(crate) struct DevelopmentProcessManager { impl DevelopmentProcessManager { pub(crate) async fn start(config: &ServeConfig) -> Result { - let engine = EngineProcessManager::start_or_reuse(EngineResolverConfig::from_parts( - &config.endpoint, - config.engine_binary_path.clone(), - config.engine_host.clone(), - config.engine_port, - config.engine_auto_download, - )) + let settings = LocalProcessSettings::resolve(config)?; + let engine = EngineProcessManager::start_or_reuse( + EngineResolverConfig::from_parts( + &config.endpoint, + settings.engine_binary_path.clone(), + settings.engine_host.clone(), + settings.engine_port, + settings.auto_download, + ) + .with_engine_version(settings.engine_version.clone()), + ) .await?; - let services = if config.start_services { - Some( - ServicesProcessManager::start(ServicesProcessConfig { - binary_path: config.services_binary_path.clone(), - endpoint: config.endpoint.clone(), - token: config.token.clone(), - namespace: config.namespace.clone(), - pool_name: SERVICES_POOL_NAME.to_owned(), - engine_protocol_version: rivet_envoy_client::protocol::PROTOCOL_VERSION, - }) - .await?, - ) + let services_config = ServicesProcessConfig { + binary_path: settings.services_binary_path.clone(), + endpoint: config.endpoint.clone(), + token: config.token.clone(), + namespace: config.namespace.clone(), + pool_name: SERVICES_POOL_NAME.to_owned(), + engine_protocol_version: rivet_envoy_client::protocol::PROTOCOL_VERSION, + auto_download: settings.auto_download, + }; + let services = if settings.start_services + && !skip_unsupported_services(&services_config, settings.services_required) + { + Some(ServicesProcessManager::start(services_config).await?) } else { None }; @@ -57,3 +149,7 @@ impl DevelopmentProcessManager { } } } + +#[cfg(test)] +#[path = "../tests/development_process.rs"] +mod tests; diff --git a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs index 345b8e04dc..f4a94a8e0f 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs @@ -3,6 +3,7 @@ //! same resolution, spawn, reuse, and orphan-lifetime logic. This module //! re-exports it for existing in-crate callers. +pub use rivetkit_engine_process::download::auto_download_from_env; pub use rivetkit_engine_process::{ EngineProcessError, EngineProcessManager, EngineResolverConfig, ResolvedEngine, engine_db_path, engine_env, resolve_engine_binary, resolve_engine_binary_path, diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs index dd7a3ca215..b0f1836cac 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/envoy_callbacks.rs @@ -147,10 +147,16 @@ impl EnvoyCallbacks for RegistryCallbacks { impl ServeSettings { fn from_env() -> anyhow::Result { - let engine_host = env::var("RIVET_RUN_ENGINE_HOST").ok(); + // The default endpoint follows the local Engine bind address. The + // development process resolver reads the same variables for binding. + let engine_host = env::var("RIVET_RUN_ENGINE_HOST") + .ok() + .filter(|value| !value.trim().is_empty()); let engine_port = env::var("RIVET_RUN_ENGINE_PORT") .ok() - .and_then(|value| value.parse().ok()); + .filter(|value| !value.trim().is_empty()) + .map(|value| parse_engine_port(&value)) + .transpose()?; // Only a configured endpoint goes through auth parsing and URL // validation, mirroring the TypeScript config transform. The built // local default is used verbatim. @@ -195,16 +201,7 @@ impl ServeSettings { .or(env_namespace) .unwrap_or_else(|| "default".to_owned()), pool_name: env::var("RIVET_POOL_NAME").unwrap_or_else(|_| "rivetkit-rust".to_owned()), - engine_binary_path: env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from), - start_services: matches!(env::var("RIVET_RUN_SERVICES").as_deref(), Ok("1")), - services_binary_path: env::var_os("RIVET_SERVICES_BINARY").map(PathBuf::from), - engine_host, - engine_port, engine_spawn: super::EngineSpawnMode::from_env(), - engine_auto_download: matches!( - env::var("RIVETKIT_ENGINE_AUTO_DOWNLOAD").as_deref(), - Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") - ), handle_inspector_http_in_runtime: false, serverless_base_path: None, serverless_package_version: env!("CARGO_PKG_VERSION").to_owned(), @@ -292,13 +289,9 @@ impl ServeConfig { token: settings.token, namespace: settings.namespace, pool_name: settings.pool_name, - engine_binary_path: settings.engine_binary_path, - start_services: settings.start_services, - services_binary_path: settings.services_binary_path, - engine_host: settings.engine_host, - engine_port: settings.engine_port, + // Local Engine and Services settings stay unset so they resolve from + // the environment exactly like the NAPI host. engine_spawn: settings.engine_spawn, - engine_auto_download: settings.engine_auto_download, handle_inspector_http_in_runtime: settings.handle_inspector_http_in_runtime, serverless_base_path: settings.serverless_base_path, serverless_package_version: settings.serverless_package_version, diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs index 47b9b967d7..11bdf78b26 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs @@ -205,13 +205,7 @@ struct ServeSettings { token: Option, namespace: String, pool_name: String, - engine_binary_path: Option, - start_services: bool, - services_binary_path: Option, - engine_host: Option, - engine_port: Option, engine_spawn: EngineSpawnMode, - engine_auto_download: bool, handle_inspector_http_in_runtime: bool, serverless_base_path: Option, serverless_package_version: String, @@ -231,7 +225,8 @@ pub enum EngineSpawnMode { } impl EngineSpawnMode { - pub(crate) fn from_env() -> Self { + /// Reads `RIVETKIT_ENGINE_SPAWN` (`always`, `never`, or unset for `Auto`). + pub fn from_env() -> Self { match env::var("RIVETKIT_ENGINE_SPAWN") { Ok(value) if value.eq_ignore_ascii_case("always") => Self::Always, Ok(value) if value.eq_ignore_ascii_case("never") => Self::Never, @@ -267,13 +262,31 @@ pub struct ServeConfig { pub token: Option, pub namespace: String, pub pool_name: String, + // The local development fields below are explicit overrides. `None` falls + // back to the environment and then the default, resolved in one place by + // `development_process::LocalProcessSettings`. + /// Engine binary to run. Falls back to `RIVET_ENGINE_BINARY_PATH`. pub engine_binary_path: Option, - pub start_services: bool, + /// Whether to run Services with a local Engine. Falls back to + /// `RIVET_RUN_SERVICES`, then on. + pub start_services: Option, + /// Services binary to run. Falls back to `RIVET_SERVICES_BINARY`. pub services_binary_path: Option, + /// Host the local Engine binds. Falls back to `RIVET_RUN_ENGINE_HOST`, then + /// the endpoint host. pub engine_host: Option, + /// Port the local Engine binds. Falls back to `RIVET_RUN_ENGINE_PORT`, then + /// the endpoint port. pub engine_port: Option, pub engine_spawn: EngineSpawnMode, - pub engine_auto_download: bool, + /// Whether missing binaries may be downloaded. Falls back to + /// `RIVETKIT_ENGINE_AUTO_DOWNLOAD`, then on. `RIVET_DISABLE_BINARY_DOWNLOAD=1` + /// always turns downloads off. + pub engine_auto_download: Option, + /// Exact Engine release to download. Falls back to + /// `RIVET_RUN_ENGINE_VERSION`, then `RIVETKIT_ENGINE_VERSION`, then this + /// build's release. + pub engine_version: Option, pub handle_inspector_http_in_runtime: bool, pub serverless_base_path: Option, pub serverless_package_version: String, @@ -350,6 +363,18 @@ impl Default for HttpActionRequestJson { } } +/// Parses `RIVET_RUN_ENGINE_PORT`. +pub(crate) fn parse_engine_port(value: &str) -> Result { + value + .trim() + .parse::() + .ok() + .filter(|port| *port != 0) + .with_context(|| { + format!("RIVET_RUN_ENGINE_PORT must be an integer between 1 and 65535, got `{value}`") + }) +} + pub(crate) fn should_manage_engine(endpoint: &str, spawn_mode: EngineSpawnMode) -> Result { match spawn_mode { EngineSpawnMode::Always => Ok(true), @@ -630,7 +655,15 @@ impl CoreRegistry { let manage_engine = should_manage_engine(&config.endpoint, config.engine_spawn)?; #[cfg(feature = "native-runtime")] let development_processes = if manage_engine { - Some(DevelopmentProcessManager::start(&config).await?) + // Starting may download binaries, so honor a shutdown request that + // arrives first. Dropping the start future removes partial downloads. + tokio::select! { + processes = DevelopmentProcessManager::start(&config) => Some(processes?), + _ = shutdown.cancelled() => { + tracing::info!("shutdown requested while starting local development processes"); + return Ok(()); + } + } } else { None }; diff --git a/rivetkit-rust/packages/rivetkit-core/src/services_process.rs b/rivetkit-rust/packages/rivetkit-core/src/services_process.rs index 41aaf7915b..fcdaa807bb 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/services_process.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/services_process.rs @@ -5,6 +5,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use reqwest::{Client, Url}; use rivet_error::RivetError; +use rivetkit_engine_process::download::{self, ReleaseBinary}; use serde::{Deserialize, Serialize}; use tokio::process::{Child, Command}; @@ -13,6 +14,15 @@ use crate::time::sleep; /// Dedicated pool used by the first-party services actor host. pub(crate) const SERVICES_POOL_NAME: &str = "services"; +/// Services release this RivetKit build runs in local development. It is +/// downloaded on demand from `releases.rivet.dev/services/{version}/services/`. +/// Bump it whenever RivetKit should run a newer Services release. +pub(crate) const SERVICES_VERSION: &str = "0.2.3"; +const SERVICES_VERSION_ENV: &str = "RIVETKIT_SERVICES_VERSION"; +const SERVICES_BINARY_ENV: &str = "RIVET_SERVICES_BINARY"; +const SERVICES_RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_SERVICES_RELEASES_ENDPOINT"; +const SERVICES_DOWNLOAD_ALTERNATIVE: &str = "set RIVET_RUN_SERVICES=0 to run without Services."; + const READINESS_MAX_ATTEMPTS: usize = 60; const READINESS_RETRY_DELAY: Duration = Duration::from_millis(500); // The child is itself a RivetKit registry. SIGTERM starts its normal envoy and @@ -28,6 +38,8 @@ pub(crate) struct ServicesProcessConfig { pub namespace: String, pub pool_name: String, pub engine_protocol_version: u16, + /// Whether a missing binary may be downloaded from releases.rivet.dev. + pub auto_download: bool, } #[derive(Debug)] @@ -55,14 +67,14 @@ enum ServicesProcessError { #[error( "binary_unavailable", "Services binary is unavailable.", - "No Services binary was provided. Install @rivet-dev/services, set RIVET_SERVICES_BINARY, or set RIVET_RUN_SERVICES=0 to disable Services." + "Rivet Services {version} is not cached and downloading it is turned off by RIVETKIT_ENGINE_AUTO_DOWNLOAD or ServeConfig::engine_auto_download. Set RIVET_SERVICES_BINARY to a rivet-services binary, turn downloads back on with RIVETKIT_ENGINE_AUTO_DOWNLOAD=1 or engine_auto_download, or set RIVET_RUN_SERVICES=0 to disable Services." )] - BinaryUnavailable, + BinaryUnavailable { version: String }, #[error( "binary_not_found", "Services binary was not found.", - "Services binary was not found at '{path}'." + "Services binary was not found at '{path}'. Point RIVET_SERVICES_BINARY at an existing rivet-services binary, unset it so RivetKit downloads Services, or set RIVET_RUN_SERVICES=0 to disable Services." )] BinaryNotFound { path: String }, @@ -76,7 +88,7 @@ enum ServicesProcessError { #[error( "protocol_mismatch", "Services is newer than the local Engine protocol.", - "Services uses Envoy protocol {services_protocol_version}, but this RivetKit Engine supports {engine_protocol_version}. Upgrade RivetKit or install an older @rivet-dev/services version." + "Services uses Envoy protocol {services_protocol_version}, but this RivetKit Engine supports {engine_protocol_version}. Upgrade RivetKit or point RIVET_SERVICES_BINARY at an older rivet-services binary." )] ProtocolMismatch { services_protocol_version: u16, @@ -100,10 +112,8 @@ enum ServicesProcessError { impl ServicesProcessManager { pub(crate) async fn start(config: ServicesProcessConfig) -> Result { - let binary_path = config - .binary_path - .as_deref() - .ok_or_else(|| ServicesProcessError::BinaryUnavailable.build())?; + let binary_path = resolve_services_binary(&config).await?; + let binary_path = binary_path.as_path(); if !binary_path.exists() { return Err(ServicesProcessError::BinaryNotFound { path: binary_path.display().to_string(), @@ -189,6 +199,115 @@ impl ServicesProcessManager { } } +/// Resolves the Services binary: the configured path (which already includes +/// `RIVET_SERVICES_BINARY`), then the pinned release from the per-user cache, +/// downloading it on first use. +async fn resolve_services_binary(config: &ServicesProcessConfig) -> Result { + if let Some(path) = &config.binary_path { + return Ok(path.clone()); + } + + let release = services_release_binary( + &std::env::var(SERVICES_VERSION_ENV).unwrap_or_else(|_| SERVICES_VERSION.to_owned()), + &std::env::var(SERVICES_RELEASES_ENDPOINT_ENV) + .unwrap_or_else(|_| download::RELEASES_ENDPOINT.to_owned()), + )?; + let cache_root = download::binary_cache_root()?; + // `auto_download` is already false when RIVET_DISABLE_BINARY_DOWNLOAD is + // set, which the downloader reports with its own error. + if !config.auto_download + && !download::download_disabled_by_env() + && !release.is_cached(&cache_root) + { + return Err(ServicesProcessError::BinaryUnavailable { + version: release.version.clone(), + } + .build()); + } + download::ensure_release_binary( + &release, + &cache_root, + config.auto_download && !download::download_disabled_by_env(), + "RivetKit runs Services next to the local Rivet Engine", + ) + .await +} + +/// Whether to run without Services because no Services binary is published for +/// this platform, no binary was provided, and Services was not explicitly +/// requested. Logs a warning with the ways to enable or silence it. +pub(crate) fn skip_unsupported_services(config: &ServicesProcessConfig, required: bool) -> bool { + if !should_skip_services( + required, + services_artifact().is_some(), + config.binary_path.is_some(), + ) { + return false; + } + tracing::warn!( + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + musl = cfg!(target_env = "musl"), + hint = "set RIVET_SERVICES_BINARY to a rivet-services binary built for this platform, or set RIVET_RUN_SERVICES=0 to hide this warning", + "Rivet Services is not published for this platform, so RivetKit is running without Services" + ); + true +} + +fn should_skip_services(required: bool, platform_supported: bool, has_binary: bool) -> bool { + !required && !platform_supported && !has_binary +} + +/// Describes the pinned Services release binary for this host. +fn services_release_binary(version: &str, releases_endpoint: &str) -> Result { + let artifact = services_artifact().ok_or_else(|| { + download::unsupported_platform( + "Rivet Services", + SERVICES_BINARY_ENV, + SERVICES_DOWNLOAD_ALTERNATIVE, + ) + })?; + download::validate_pinned_version("Rivet Services", version)?; + let platform = artifact + .trim_start_matches("rivet-services-") + .trim_end_matches(".exe"); + Ok(ReleaseBinary { + display_name: "Rivet Services", + cache_name: "services", + version: version.to_owned(), + platform: platform.to_owned(), + base_url: format!( + "{}/services/{version}/services", + releases_endpoint.trim_end_matches('/') + ), + artifact: artifact.to_owned(), + file_name: if cfg!(windows) { + "rivet-services.exe".to_owned() + } else { + "rivet-services".to_owned() + }, + override_env: SERVICES_BINARY_ENV, + alternative: SERVICES_DOWNLOAD_ALTERNATIVE, + }) +} + +/// Release artifact name of the Services binary for this host, or `None` when +/// no release binary is published for it. Services ships glibc builds only, +/// so musl hosts are unsupported. +fn services_artifact() -> Option<&'static str> { + if cfg!(target_env = "musl") { + return None; + } + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("rivet-services-linux-x64-gnu"), + ("linux", "aarch64") => Some("rivet-services-linux-arm64-gnu"), + ("macos", "x86_64") => Some("rivet-services-darwin-x64"), + ("macos", "aarch64") => Some("rivet-services-darwin-arm64"), + ("windows", "x86_64") => Some("rivet-services-win32-x64.exe"), + _ => None, + } +} + async fn validate_binary_compatibility( binary_path: &Path, config: &ServicesProcessConfig, @@ -209,9 +328,9 @@ async fn validate_binary_compatibility( engine_protocol_version = config.engine_protocol_version, "validated Services binary metadata" ); - // This protocol comparison is the compatibility safeguard. Services package - // semver is diagnostic only and must be bumped whenever its Envoy protocol - // version changes so package resolution can select a compatible binary. + // This protocol comparison is the compatibility safeguard. The Services + // version is diagnostic only, so bump `SERVICES_VERSION` to a release whose + // Envoy protocol this RivetKit supports. validate_protocol_version(output.protocol_version, config.engine_protocol_version) } @@ -327,55 +446,5 @@ async fn wait_for_readiness( } #[cfg(test)] -mod tests { - use super::*; - - fn config(binary_path: Option) -> ServicesProcessConfig { - ServicesProcessConfig { - binary_path, - endpoint: "http://127.0.0.1:6420".to_owned(), - token: Some("dev".to_owned()), - namespace: "default".to_owned(), - pool_name: SERVICES_POOL_NAME.to_owned(), - engine_protocol_version: 7, - } - } - - #[test] - fn parses_structured_version_output() { - let output: ServicesVersionOutput = serde_json::from_str( - r#"{"name":"rivet-services","version":"0.2.0-rc.1","rivetkitVersion":"2.3.11","protocolVersion":7}"#, - ) - .expect("version output should parse"); - assert_eq!(output.name, "rivet-services"); - assert_eq!(output.version, "0.2.0-rc.1"); - assert_eq!(output.rivetkit_version, "2.3.11"); - assert_eq!(output.protocol_version, 7); - } - - #[tokio::test] - async fn missing_binary_is_a_structured_error() { - let error = ServicesProcessManager::start(config(None)) - .await - .expect_err("missing binary should fail"); - - let error = rivet_error::RivetError::extract(&error); - assert_eq!(error.group(), "services"); - assert_eq!(error.code(), "binary_unavailable"); - } - - #[test] - fn rejects_a_newer_protocol() { - let error = - validate_protocol_version(8, 7).expect_err("newer Services protocol should fail"); - let error = rivet_error::RivetError::extract(&error); - assert_eq!(error.group(), "services"); - assert_eq!(error.code(), "protocol_mismatch"); - } - - #[test] - fn accepts_an_equal_or_older_protocol() { - validate_protocol_version(7, 7).expect("equal protocol should be compatible"); - validate_protocol_version(6, 7).expect("older protocol should be compatible"); - } -} +#[path = "../tests/services_process.rs"] +mod tests; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/development_process.rs b/rivetkit-rust/packages/rivetkit-core/tests/development_process.rs new file mode 100644 index 0000000000..c76c5ea06c --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/tests/development_process.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; + +use super::*; + +fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let vars = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect::>(); + move |key| vars.get(key).cloned() +} + +fn config() -> ServeConfig { + ServeConfig { + endpoint: "http://127.0.0.1:6420".to_owned(), + ..ServeConfig::default() + } +} + +#[test] +fn defaults_download_and_run_services_without_requiring_them() { + let settings = LocalProcessSettings::resolve_with(&config(), env(&[])).expect("resolve"); + assert!(settings.auto_download, "Rust and TypeScript hosts download by default"); + assert!(settings.start_services); + assert!(!settings.services_required); + assert_eq!(settings.engine_version, None); + assert_eq!(settings.engine_host, None); + assert_eq!(settings.engine_port, None); + assert_eq!(settings.engine_binary_path, None); + assert_eq!(settings.services_binary_path, None); +} + +#[test] +fn environment_fills_unset_config() { + let settings = LocalProcessSettings::resolve_with( + &config(), + env(&[ + ("RIVET_RUN_ENGINE_VERSION", "2.3.29"), + ("RIVET_RUN_ENGINE_HOST", "0.0.0.0"), + ("RIVET_RUN_ENGINE_PORT", "7000"), + ("RIVET_ENGINE_BINARY_PATH", "/opt/rivet-engine"), + ("RIVET_SERVICES_BINARY", "/opt/rivet-services"), + ("RIVETKIT_ENGINE_AUTO_DOWNLOAD", "0"), + ]), + ) + .expect("resolve"); + assert_eq!(settings.engine_version.as_deref(), Some("2.3.29")); + assert_eq!(settings.engine_host.as_deref(), Some("0.0.0.0")); + assert_eq!(settings.engine_port, Some(7000)); + assert_eq!( + settings.engine_binary_path, + Some(PathBuf::from("/opt/rivet-engine")) + ); + assert_eq!( + settings.services_binary_path, + Some(PathBuf::from("/opt/rivet-services")) + ); + assert!(!settings.auto_download); +} + +#[test] +fn explicit_config_beats_environment() { + let config = ServeConfig { + engine_version: Some("2.3.30".to_owned()), + engine_host: Some("127.0.0.1".to_owned()), + engine_port: Some(6500), + start_services: Some(false), + engine_auto_download: Some(true), + ..config() + }; + let settings = LocalProcessSettings::resolve_with( + &config, + env(&[ + ("RIVET_RUN_ENGINE_VERSION", "2.3.29"), + ("RIVETKIT_ENGINE_VERSION", "2.3.28"), + ("RIVET_RUN_ENGINE_HOST", "0.0.0.0"), + ("RIVET_RUN_ENGINE_PORT", "7000"), + ("RIVET_RUN_SERVICES", "1"), + ("RIVETKIT_ENGINE_AUTO_DOWNLOAD", "0"), + ]), + ) + .expect("resolve"); + assert_eq!(settings.engine_version.as_deref(), Some("2.3.30")); + assert_eq!(settings.engine_host.as_deref(), Some("127.0.0.1")); + assert_eq!(settings.engine_port, Some(6500)); + assert!(!settings.start_services); + assert!(settings.auto_download); +} + +#[test] +fn disable_download_env_beats_explicit_config() { + let config = ServeConfig { + engine_auto_download: Some(true), + ..config() + }; + let settings = LocalProcessSettings::resolve_with( + &config, + env(&[("RIVET_DISABLE_BINARY_DOWNLOAD", "1")]), + ) + .expect("resolve"); + assert!(!settings.auto_download); +} + +#[test] +fn run_services_env_is_an_explicit_choice() { + let on = LocalProcessSettings::resolve_with(&config(), env(&[("RIVET_RUN_SERVICES", "1")])) + .expect("resolve"); + assert!(on.start_services); + assert!(on.services_required, "an explicit request fails on unsupported platforms"); + + let off = LocalProcessSettings::resolve_with(&config(), env(&[("RIVET_RUN_SERVICES", "0")])) + .expect("resolve"); + assert!(!off.start_services); + + let requested = ServeConfig { + start_services: Some(true), + ..config() + }; + let requested = LocalProcessSettings::resolve_with(&requested, env(&[])).expect("resolve"); + assert!(requested.start_services); + assert!(requested.services_required); +} + +#[test] +fn empty_environment_values_are_ignored() { + let settings = LocalProcessSettings::resolve_with( + &config(), + env(&[ + ("RIVET_RUN_SERVICES", ""), + ("RIVET_RUN_ENGINE_PORT", " "), + ("RIVET_RUN_ENGINE_VERSION", ""), + ("RIVET_ENGINE_BINARY_PATH", ""), + ]), + ) + .expect("resolve"); + assert!(settings.start_services); + assert!(!settings.services_required); + assert_eq!(settings.engine_port, None); + assert_eq!(settings.engine_version, None); + assert_eq!(settings.engine_binary_path, None); +} + +#[test] +fn rejects_an_invalid_engine_port() { + let error = + LocalProcessSettings::resolve_with(&config(), env(&[("RIVET_RUN_ENGINE_PORT", "abc")])) + .expect_err("invalid port should fail"); + assert!( + error.to_string().contains("RIVET_RUN_ENGINE_PORT"), + "error should name the variable: {error}" + ); +} diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs index 23b7c0f43c..c886e777f9 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs @@ -100,7 +100,7 @@ impl IntegrationCtx { pool_name: DEFAULT_POOL.to_owned(), engine_binary_path: None, engine_spawn: EngineSpawnMode::Never, - engine_auto_download: false, + engine_auto_download: Some(false), ..ServeConfig::default() }; let task = tokio::spawn({ diff --git a/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs b/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs index 1a25f4c92d..08064f4ef4 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/serverless.rs @@ -347,12 +347,13 @@ mod moved_tests { namespace: "default".to_owned(), pool_name: "default".to_owned(), engine_binary_path: None, - start_services: false, + start_services: Some(false), services_binary_path: None, engine_host: None, engine_port: None, engine_spawn: EngineSpawnMode::Never, - engine_auto_download: false, + engine_auto_download: Some(false), + engine_version: None, handle_inspector_http_in_runtime: true, serverless_base_path: Some("/api/rivet".to_owned()), serverless_package_version: "test-version".to_owned(), diff --git a/rivetkit-rust/packages/rivetkit-core/tests/services_process.rs b/rivetkit-rust/packages/rivetkit-core/tests/services_process.rs new file mode 100644 index 0000000000..0b918648e3 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/tests/services_process.rs @@ -0,0 +1,127 @@ +use super::*; + +fn config(binary_path: Option) -> ServicesProcessConfig { + ServicesProcessConfig { + binary_path, + endpoint: "http://127.0.0.1:6420".to_owned(), + token: Some("dev".to_owned()), + namespace: "default".to_owned(), + pool_name: SERVICES_POOL_NAME.to_owned(), + engine_protocol_version: 7, + auto_download: false, + } +} + +#[test] +fn parses_structured_version_output() { + let output: ServicesVersionOutput = serde_json::from_str( + r#"{"name":"rivet-services","version":"0.2.0-rc.1","rivetkitVersion":"2.3.11","protocolVersion":7}"#, + ) + .expect("version output should parse"); + assert_eq!(output.name, "rivet-services"); + assert_eq!(output.version, "0.2.0-rc.1"); + assert_eq!(output.rivetkit_version, "2.3.11"); + assert_eq!(output.protocol_version, 7); +} + +#[tokio::test] +async fn missing_binary_is_a_structured_error() { + let error = ServicesProcessManager::start(config(Some(PathBuf::from( + "/nonexistent/rivet-services", + )))) + .await + .expect_err("missing binary should fail"); + + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.group(), "services"); + assert_eq!(error.code(), "binary_not_found"); + assert!( + error.message().contains("RIVET_SERVICES_BINARY"), + "resolution should name the setting to fix: {}", + error.message() + ); +} + +#[test] +fn rejects_a_newer_protocol() { + let error = + validate_protocol_version(8, 7).expect_err("newer Services protocol should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.group(), "services"); + assert_eq!(error.code(), "protocol_mismatch"); +} + +#[test] +fn accepts_an_equal_or_older_protocol() { + validate_protocol_version(7, 7).expect("equal protocol should be compatible"); + validate_protocol_version(6, 7).expect("older protocol should be compatible"); +} + +#[test] +fn services_release_uses_pinned_version_path() { + let release = services_release_binary("0.2.3", "https://releases.rivet.dev/") + .expect("Services release for host"); + assert_eq!( + release.base_url, + "https://releases.rivet.dev/services/0.2.3/services" + ); + assert!(release.artifact.starts_with("rivet-services-")); + assert_eq!( + release.manifest_url(), + "https://releases.rivet.dev/services/0.2.3/services/SHA256SUMS" + ); +} + +#[tokio::test] +async fn explicit_binary_path_skips_download() { + let path = PathBuf::from("/nonexistent/rivet-services"); + let resolved = resolve_services_binary(&config(Some(path.clone()))) + .await + .expect("explicit path resolves"); + assert_eq!(resolved, path); +} + +#[test] +fn services_release_rejects_path_traversal_version() { + let error = services_release_binary("../0.2.3", "https://releases.rivet.dev") + .expect_err("traversal version should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.group(), "binary_download"); + assert_eq!(error.code(), "invalid_version"); +} + +#[test] +fn skips_services_only_when_unsupported_and_not_requested() { + assert!(should_skip_services(false, false, false)); + assert!(!should_skip_services(true, false, false), "explicit Services keeps the hard error"); + assert!(!should_skip_services(false, true, false), "supported platforms start Services"); + assert!(!should_skip_services(false, false, true), "a provided binary is used"); +} + +#[test] +fn services_release_rejects_unpinned_version() { + let error = services_release_binary("latest", "https://releases.rivet.dev") + .expect_err("alias should fail"); + let error = rivet_error::RivetError::extract(&error); + assert_eq!(error.code(), "unpinned_version"); +} + +#[test] +fn binary_unavailable_names_the_setting_that_disabled_downloads() { + // This error is only returned when RIVETKIT_ENGINE_AUTO_DOWNLOAD turned + // downloads off. RIVET_DISABLE_BINARY_DOWNLOAD reports its own error. + let error = ServicesProcessError::BinaryUnavailable { + version: SERVICES_VERSION.to_owned(), + } + .build(); + let message = rivet_error::RivetError::extract(&error).message().to_owned(); + assert!( + message.contains("RIVETKIT_ENGINE_AUTO_DOWNLOAD=1"), + "resolution should name the setting to change: {message}" + ); + assert!(message.contains(SERVICES_VERSION), "message should name the version: {message}"); + assert!( + !message.contains("RIVET_DISABLE_BINARY_DOWNLOAD"), + "this error is never caused by RIVET_DISABLE_BINARY_DOWNLOAD: {message}" + ); +} diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts index af10f8fe0d..6912d55259 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts @@ -227,10 +227,13 @@ export interface JsServeConfig { namespace: string poolName: string engineBinaryPath?: string + /** Whether to run Services with a local Engine, if set in code. */ startServices?: boolean servicesBinaryPath?: string engineHost?: string enginePort?: number + /** Exact Engine release to download for a local Engine, if set in code. */ + engineVersion?: string handleInspectorHttpInRuntime?: boolean serverlessBasePath?: string serverlessPackageVersion: string diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs index f0c372c676..ce58e30ebc 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs @@ -113,12 +113,15 @@ fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Val pub(crate) fn init_tracing(log_level: Option<&str>) { INIT_TRACING.call_once(|| { // Priority: explicit config > RIVET_LOG_LEVEL > LOG_LEVEL > RUST_LOG > "warn" + // plus download progress. let filter = log_level .map(String::from) .or_else(|| std::env::var("RIVET_LOG_LEVEL").ok()) .or_else(|| std::env::var("LOG_LEVEL").ok()) .or_else(|| std::env::var("RUST_LOG").ok()) - .unwrap_or_else(|| "warn".to_string()); + // Binary download progress is shown by default because a first + // local run can otherwise stall silently for a minute. + .unwrap_or_else(|| "warn,rivetkit_engine_process::download=info".to_string()); let log_filter = format!("{filter},rivetkit::telemetry=off"); let log_format = LogFormat::from_env(); diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs b/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs index a1ef60acdb..5afbc4cf2d 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/registry.rs @@ -33,10 +33,13 @@ pub struct JsServeConfig { pub namespace: String, pub pool_name: String, pub engine_binary_path: Option, + /// Whether to run Services with a local Engine, if set in code. pub start_services: Option, pub services_binary_path: Option, pub engine_host: Option, pub engine_port: Option, + /// Exact Engine release to download for a local Engine, if set in code. + pub engine_version: Option, pub handle_inspector_http_in_runtime: Option, pub serverless_base_path: Option, pub serverless_package_version: String, @@ -838,12 +841,15 @@ fn serve_config_from_js( namespace: config.namespace, pool_name: config.pool_name, engine_binary_path: config.engine_binary_path.map(PathBuf::from), - start_services: config.start_services.unwrap_or(false), + // Local Engine and Services fields carry only values set in code. Core + // resolves the environment and defaults, matching Rust hosts. + start_services: config.start_services, services_binary_path: config.services_binary_path.map(PathBuf::from), engine_host: config.engine_host, engine_port: config.engine_port, - engine_spawn: EngineSpawnMode::Auto, - engine_auto_download: false, + engine_spawn: EngineSpawnMode::from_env(), + engine_auto_download: None, + engine_version: config.engine_version, handle_inspector_http_in_runtime: config .handle_inspector_http_in_runtime .unwrap_or(default_handle_inspector_http_in_runtime), diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index 57b8eebe37..b604fba3be 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -150,12 +150,13 @@ impl From for ServeConfig { namespace: config.namespace, pool_name: config.pool_name, engine_binary_path: config.engine_binary_path.map(PathBuf::from), - start_services: false, + start_services: Some(false), services_binary_path: None, engine_host: None, engine_port: None, engine_spawn: EngineSpawnMode::Never, - engine_auto_download: false, + engine_auto_download: Some(false), + engine_version: None, handle_inspector_http_in_runtime: config .handle_inspector_http_in_runtime .unwrap_or(false), diff --git a/rivetkit-typescript/packages/rivetkit/package.json b/rivetkit-typescript/packages/rivetkit/package.json index 04cef0dbaf..61fb5a272d 100644 --- a/rivetkit-typescript/packages/rivetkit/package.json +++ b/rivetkit-typescript/packages/rivetkit/package.json @@ -210,10 +210,8 @@ "dependencies": { "@hono/zod-openapi": "^1.1.5", "@opentelemetry/api": "^1.1.0", - "@rivet-dev/services": "^0.2.3", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-api-full": "workspace:*", - "@rivetkit/engine-cli": "workspace:*", "@rivetkit/engine-envoy-protocol": "workspace:*", "@rivetkit/on-change": "6.0.1", "@rivetkit/rivetkit-napi": "workspace:*", @@ -237,6 +235,7 @@ "@hono/node-server": "^1.18.2", "@hono/node-ws": "^1.1.1", "@opentelemetry/sdk-trace-node": "2.11.0", + "@rivetkit/engine-cli": "workspace:*", "@standard-schema/spec": "^1.0.0", "@types/invariant": "^2", "@types/node": "^22.13.1", diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts index 63d2b43a1c..4c986cef7e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts @@ -8,7 +8,6 @@ import { isLocalEngineEndpoint, } from "@/common/engine"; import { type Logger, LogLevelSchema } from "@/common/log"; -import { VERSION } from "@/utils"; import { tryParseEndpoint } from "@/utils/endpoint-parser"; import { getRivetEndpoint, @@ -18,8 +17,6 @@ import { getRivetRunEngine, getRivetRunEngineHost, getRivetRunEnginePort, - getRivetRunEngineVersion, - getRivetRunServices, getRivetToken, isDev, } from "@/utils/env-vars"; @@ -226,33 +223,29 @@ export const RegistryConfigSchema = z /** * @experimental * - * Host to bind the spawned local engine process to. + * Host to bind the spawned local engine process to. Defaults to + * RIVET_RUN_ENGINE_HOST, then 127.0.0.1. */ - engineHost: z - .string() - .optional() - .default(() => getRivetRunEngineHost() ?? ENGINE_HOST), + engineHost: z.string().optional(), /** * @experimental * - * Port to bind the spawned local engine process to. + * Port to bind the spawned local engine process to. Defaults to + * RIVET_RUN_ENGINE_PORT, then 6420. */ - enginePort: z - .number() - .int() - .min(1) - .max(65_535) - .optional() - .default(() => getRivetRunEnginePort() ?? ENGINE_PORT), - /** @experimental */ - engineVersion: z - .string() - .optional() - .default(() => getRivetRunEngineVersion() ?? VERSION), + enginePort: z.number().int().min(1).max(65_535).optional(), + /** + * @experimental + * + * Exact Engine release to download when RivetKit starts a local Engine, + * such as `2.3.30`. Defaults to RIVET_RUN_ENGINE_VERSION, then the + * Engine built with this RivetKit release. Resolved by the native core. + */ + engineVersion: z.string().optional(), /** * Starts the first-party Services actor host when RivetKit manages - * a local engine. Defaults to the local-engine decision. Set - * RIVET_RUN_SERVICES=0 or 1 to override it. + * a local engine. Defaults to RIVET_RUN_SERVICES, then on. Resolved by + * the native core. */ startServices: z.boolean().optional(), /** @@ -357,22 +350,18 @@ export const RegistryConfigSchema = z }); } - // Flatten the endpoint and apply defaults for namespace/token. + // Flatten the endpoint and apply defaults for namespace/token. The + // in-process client needs the local endpoint synchronously, so its host + // and port defaults stay here. The native core resolves the same + // variables when it binds the spawned Engine. const localEngineEndpoint = buildEngineEndpoint( - config.engineHost, - config.enginePort, + config.engineHost ?? getRivetRunEngineHost() ?? ENGINE_HOST, + config.enginePort ?? getRivetRunEnginePort() ?? ENGINE_PORT, ); const endpoint = config.startEngine ? localEngineEndpoint : (parsedEndpoint?.endpoint ?? (isDevEnv ? localEngineEndpoint : undefined)); - // Services is another local development actor host. Its default - // follows the same local-endpoint decision that causes core to manage the - // Engine, while explicit config and RIVET_RUN_SERVICES take precedence. - const startServices = - config.startServices ?? - getRivetRunServices() ?? - Boolean(endpoint && isLocalEngineEndpoint(endpoint)); const validateServerlessEndpoint = Boolean( config.startEngine || parsedEndpoint, ); @@ -422,7 +411,6 @@ export const RegistryConfigSchema = z ...config, sqlite, endpoint, - startServices, namespace, token, publicEndpoint, @@ -652,13 +640,13 @@ export const DocRegistryConfigSchema = z .string() .optional() .describe( - "Version of the local engine package to use. Defaults to the current RivetKit version.", + "Exact Engine release to download for a local Engine, such as 2.3.30. Defaults to RIVET_RUN_ENGINE_VERSION, then the Engine built with this RivetKit release. Aliases such as latest are rejected.", ), startServices: z .boolean() .optional() .describe( - "Starts Services when RivetKit manages a local Engine. Defaults to the local-Engine decision. Set RIVET_RUN_SERVICES=0 or 1 to override.", + "Starts Services when RivetKit manages a local Engine. Defaults to RIVET_RUN_SERVICES, then on. Setting it explicitly makes a platform without a Services build an error instead of a warning.", ), configurePool: DocConfigurePoolSchema.describe( "Automatically configure serverless runners in the engine.", diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 0c5c55648e..13f5b540b5 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -773,23 +773,6 @@ function decodeNativeKvValue( } } -async function loadEngineCli(): Promise { - // LOAD-BEARING: the specifier is computed (`.join("/")`) on purpose. Do NOT - // "simplify" it to a literal `import("@rivetkit/engine-cli")`. Edge adapters - // (e.g. @rivetkit/supabase) pre-bundle this module for Deno; a literal - // dynamic import is statically resolvable, so Deno's eszip bundler would - // snapshot the ~117 MB engine-cli binary into the deploy and 413. The - // computed specifier keeps it opaque to static analysis so it is never - // bundled. Enforced by scripts/ci/check-edge-native-closure.mjs. - return import(["@rivetkit", "engine-cli"].join("/")); -} - -async function loadServices(): Promise { - // Keep the native binary opaque to edge bundlers for the same reason as - // engine-cli above. - return import(["@rivet-dev", "services"].join("/")); -} - function decodeValue(value?: RuntimeBytes | null): T { if (!value || value.length === 0) { return undefined as T; @@ -5591,7 +5574,7 @@ export function buildNativeFactory( export async function buildServeConfig( config: RegistryConfig, - resolveServicesBinary = true, + canStartServices = true, ): Promise { if (!config.endpoint) { throw nativeEndpointNotConfiguredError(); @@ -5603,7 +5586,9 @@ export async function buildServeConfig( token: config.token, namespace: config.namespace, poolName: config.envoy.poolName, - startServices: resolveServicesBinary && config.startServices, + // Only values set in code are passed. The core resolves environment + // variables and defaults for local Engine and Services startup. + startServices: canStartServices ? config.startServices : false, handleInspectorHttpInRuntime: true, serverlessBasePath: config.serverless.basePath, serverlessPackageVersion: VERSION, @@ -5614,40 +5599,11 @@ export async function buildServeConfig( serverlessMaxStartPayloadBytes: config.serverless.maxStartPayloadBytes, }; - // Always best-effort resolve the npm-installed engine binary and hand its - // path to the core. The core alone decides whether to actually spawn a local - // engine (its `should_manage_engine`, based on the endpoint + spawn mode), so - // JS must not duplicate that decision here. Only JS knows the npm - // `node_modules` layout, so it resolves the path; if no binary is available - // (remote-only install, unsupported platform, optional deps skipped), leave - // it unset and let the core report `engine.binary_unavailable` if it actually - // needs one. - try { - const { getEnginePath } = await loadEngineCli(); - serveConfig.engineBinaryPath = getEnginePath(); - } catch (error) { - // The npm-installed engine binary could not be resolved. The core still - // decides whether it needs to spawn a local engine; if it does, it will - // fail with engine.binary_unavailable (auto-download is off in the napi - // runtime). Warn so the cause is actionable. - logger().warn({ - msg: "could not resolve a local engine binary; if a local engine must be spawned it will fail with engine.binary_unavailable — set RIVET_ENGINE_BINARY_PATH or install the @rivetkit/engine-cli platform package", - error: stringifyError(error), - }); - } - if (resolveServicesBinary && config.startServices) { - try { - const { getServicesPath } = await loadServices(); - serveConfig.servicesBinaryPath = getServicesPath(); - } catch (error) { - logger().warn({ - msg: "could not resolve the Services binary; a local engine will fail with services.binary_unavailable — set RIVET_RUN_SERVICES=0 to disable Services or RIVET_SERVICES_BINARY to a local rivet-services binary", - error: stringifyError(error), - }); - } - } + // The core resolves the local Engine and Services binaries itself, only + // when it actually spawns them. serveConfig.engineHost = config.engineHost; serveConfig.enginePort = config.enginePort; + serveConfig.engineVersion = config.engineVersion; if (config.test?.enabled) { serveConfig.inspectorTestToken = getEnvUniversal("_RIVET_TEST_INSPECTOR_TOKEN") ?? "token"; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index 286868ff45..4938fcefb2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -7,9 +7,6 @@ import type { SqliteNativeMetrics, SqliteProfilingOptions, } from "@/common/database/config"; -import { stringifyError } from "@/common/utils"; -import type { RegistryConfig } from "./config"; -import { logger } from "./log"; declare const handleBrand: unique symbol; @@ -385,10 +382,13 @@ export interface RuntimeServeConfig { namespace: string; poolName: string; engineBinaryPath?: string; + /** Whether to run Services with a local Engine, if set in code. */ startServices?: boolean; servicesBinaryPath?: string; engineHost?: string; enginePort?: number; + /** Exact Engine release to download for a local Engine, if set in code. */ + engineVersion?: string; handleInspectorHttpInRuntime?: boolean; inspectorTestToken?: string; serverlessBasePath?: string; @@ -875,67 +875,3 @@ export interface CoreRuntime { export interface RuntimeBundle { runtime: CoreRuntime; } - -export async function buildServeConfig( - config: RegistryConfig, - loadEnginePath: () => Promise, - version: string, - loadServicesPath: () => Promise, -): Promise { - if (!config.endpoint) { - throw new Error("registry endpoint is required"); - } - - const serveConfig: RuntimeServeConfig = { - version: config.envoy.version, - endpoint: config.endpoint, - token: config.token, - namespace: config.namespace, - poolName: config.envoy.poolName, - startServices: config.startServices, - handleInspectorHttpInRuntime: true, - serverlessBasePath: config.serverless.basePath, - serverlessPackageVersion: version, - serverlessClientEndpoint: config.publicEndpoint, - serverlessClientNamespace: config.publicNamespace, - serverlessClientToken: config.publicToken, - serverlessValidateEndpoint: config.validateServerlessEndpoint, - serverlessMaxStartPayloadBytes: config.serverless.maxStartPayloadBytes, - }; - - // Always best-effort resolve the engine binary path and hand it to the core. - // The core alone decides whether to actually spawn a local engine, so JS must - // not duplicate that decision here. `loadEnginePath` throws when no binary is - // available (remote-only install, unsupported platform, optional deps - // skipped); in that case leave it unset and let the core report - // `engine.binary_unavailable` only if it actually needs one. - try { - serveConfig.engineBinaryPath = await loadEnginePath(); - } catch (error) { - // The engine binary could not be resolved. The core still decides whether - // it needs to spawn a local engine; if it does, it will fail with - // engine.binary_unavailable (auto-download is off in the napi runtime). - logger().warn({ - msg: "could not resolve a local engine binary; if a local engine must be spawned it will fail with engine.binary_unavailable — set RIVET_ENGINE_BINARY_PATH or install the @rivetkit/engine-cli platform package", - error: stringifyError(error), - }); - } - if (config.startServices) { - try { - serveConfig.servicesBinaryPath = await loadServicesPath(); - } catch (error) { - logger().warn({ - msg: "could not resolve the Services binary; a local Engine will fail with services.binary_unavailable — set RIVET_RUN_SERVICES=0 to disable Services or RIVET_SERVICES_BINARY to a local rivet-services binary", - error: stringifyError(error), - }); - } - } - serveConfig.engineHost = config.engineHost; - serveConfig.enginePort = config.enginePort; - if (config.test?.enabled) { - serveConfig.inspectorTestToken = - process.env._RIVET_TEST_INSPECTOR_TOKEN ?? "token"; - } - - return serveConfig; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/utils/env-vars.ts b/rivetkit-typescript/packages/rivetkit/src/utils/env-vars.ts index 807cc80a5a..2cb5b3bf41 100644 --- a/rivetkit-typescript/packages/rivetkit/src/utils/env-vars.ts +++ b/rivetkit-typescript/packages/rivetkit/src/utils/env-vars.ts @@ -28,12 +28,8 @@ export const getRivetRunEnginePort = (): number | undefined => { const value = getEnvUniversal("RIVET_RUN_ENGINE_PORT"); return value !== undefined ? parseInt(value, 10) : undefined; }; -export const getRivetRunEngineVersion = (): string | undefined => - getEnvUniversal("RIVET_RUN_ENGINE_VERSION"); -export const getRivetRunServices = (): boolean | undefined => { - const value = getEnvUniversal("RIVET_RUN_SERVICES"); - return value === undefined ? undefined : value === "1"; -}; +// RIVET_RUN_ENGINE_VERSION, RIVET_RUN_SERVICES, binary paths, and download +// settings are resolved by the native core, not here. export const getRivetEnvoyKind = (): string | undefined => getEnvUniversal("RIVET_ENVOY_KIND"); export const getRivetEnvoyVersion = (): number | undefined => { diff --git a/rivetkit-typescript/packages/rivetkit/tests/registry-constructor.test.ts b/rivetkit-typescript/packages/rivetkit/tests/registry-constructor.test.ts index cf1bc76daf..9336e9fa53 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/registry-constructor.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/registry-constructor.test.ts @@ -3,14 +3,6 @@ import { actor, setup } from "@/mod"; import { RegistryConfigSchema } from "@/registry/config"; import { buildNativeRegistry, buildServeConfig } from "../src/registry/native"; -vi.mock("@rivetkit/engine-cli", () => ({ - getEnginePath: () => "/tmp/rivet-engine", -})); - -vi.mock("@rivet-dev/services", () => ({ - getServicesPath: () => "/tmp/rivet-services", -})); - const testActor = actor({ state: {}, actions: {}, @@ -97,40 +89,70 @@ describe("Registry constructor", () => { expect(serveConfig.endpoint).toBe("http://127.0.0.1:7654"); expect(serveConfig.engineHost).toBe("127.0.0.1"); expect(serveConfig.enginePort).toBe(7654); - expect(serveConfig.startServices).toBe(true); - expect(serveConfig.servicesBinaryPath).toBe("/tmp/rivet-services"); + // Unset values stay unset so the core resolves env and defaults. + expect(serveConfig.startServices).toBeUndefined(); + // The core resolves or downloads binaries only when it spawns them. + expect(serveConfig.engineBinaryPath).toBeUndefined(); + expect(serveConfig.servicesBinaryPath).toBeUndefined(); + expect(serveConfig.engineVersion).toBeUndefined(); }); - test("allows Services to be disabled", async () => { + test("passes explicitly requested Services to the core", async () => { const config = RegistryConfigSchema.parse({ use: { test: testActor }, startEngine: true, - startServices: false, + startServices: true, }); const serveConfig = await buildServeConfig(config); - expect(serveConfig.startServices).toBe(false); - expect(serveConfig.servicesBinaryPath).toBeUndefined(); + expect(serveConfig.startServices).toBe(true); }); - test("does not enable Services for a remote Engine by default", () => { + test("passes the configured Engine version to the core", async () => { const config = RegistryConfigSchema.parse({ use: { test: testActor }, - endpoint: "https://api.rivet.dev", + startEngine: true, + engineVersion: "2.3.29", }); - expect(config.startServices).toBe(false); + const serveConfig = await buildServeConfig(config); + + expect(serveConfig.engineVersion).toBe("2.3.29"); }); - test("RIVET_RUN_SERVICES can disable services for a local engine", () => { + test("leaves local Engine and Services env vars to the core", async () => { + vi.stubEnv("RIVET_RUN_ENGINE_VERSION", "2.3.28"); vi.stubEnv("RIVET_RUN_SERVICES", "0"); + vi.stubEnv("RIVET_RUN_ENGINE_HOST", "127.0.0.1"); + vi.stubEnv("RIVET_RUN_ENGINE_PORT", "7656"); + const config = RegistryConfigSchema.parse({ + use: { test: testActor }, + startEngine: true, + }); + + const serveConfig = await buildServeConfig(config); + + // The endpoint still follows the run-engine host and port so the + // in-process client can reach the spawned Engine. + expect(serveConfig.endpoint).toBe("http://127.0.0.1:7656"); + expect(serveConfig.engineVersion).toBeUndefined(); + expect(serveConfig.startServices).toBeUndefined(); + expect(serveConfig.engineHost).toBeUndefined(); + expect(serveConfig.enginePort).toBeUndefined(); + }); + + test("allows Services to be disabled", async () => { const config = RegistryConfigSchema.parse({ use: { test: testActor }, startEngine: true, + startServices: false, }); - expect(config.startServices).toBe(false); + const serveConfig = await buildServeConfig(config); + + expect(serveConfig.startServices).toBe(false); + expect(serveConfig.servicesBinaryPath).toBeUndefined(); }); test("does not resolve Services for the wasm runtime", async () => {