From da9b0d2f48a68bd56cb208fc4e698cab81c5be6d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 02:21:56 +0800 Subject: [PATCH 1/8] feat(cache): upload task results to the remote cache Add the `vt_remote_cache` crate with the store operation, encode local cache entries for the remote cache, and upload them after a local update in `read-write` mode. A failed upload is a warning in the run summary. The remote cache test server prints the requests it served after the wrapped command exits. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + Cargo.lock | 1062 ++++++++++++++++- Cargo.toml | 6 + crates/vt/Cargo.toml | 1 + crates/vt/src/session/cache/mod.rs | 31 +- crates/vt/src/session/cache/remote.rs | 97 ++ crates/vt/src/session/event.rs | 8 +- crates/vt/src/session/execute/cache_update.rs | 2 +- crates/vt/src/session/reporter/summary.rs | 134 ++- .../fixtures/remote_cache/package.json | 4 + .../fixtures/remote_cache/snapshots.toml | 118 ++ .../snapshots/invalid_endpoint.md | 42 + .../fixtures/remote_cache/snapshots/read.md | 22 + .../remote_cache/snapshots/read_write.md | 22 + .../snapshots/unreachable_endpoint.md | 12 + .../fixtures/remote_cache/src/a.txt | 1 + .../fixtures/remote_cache/vite-task.json | 11 + .../snapshots/blob_replacement.md | 13 + .../snapshots/entries_and_associations.md | 11 + .../snapshots/invalid_requests.md | 19 + .../snapshots/opaque_binary_data.md | 6 + .../snapshots/state_across_invocations.md | 4 + crates/vt_remote_cache/Cargo.toml | 30 + crates/vt_remote_cache/README.md | 11 + crates/vt_remote_cache/src/lib.rs | 281 +++++ deny.toml | 2 + packages/tools/README.md | 8 + packages/tools/src/remote-cache/cli.ts | 8 +- packages/tools/src/remote-cache/server.ts | 35 +- 29 files changed, 1932 insertions(+), 70 deletions(-) create mode 100644 crates/vt/src/session/cache/remote.rs create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_write.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/src/a.txt create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/vite-task.json create mode 100644 crates/vt_remote_cache/Cargo.toml create mode 100644 crates/vt_remote_cache/README.md create mode 100644 crates/vt_remote_cache/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cadeec13..0a768233c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Added** Remote caching. Configure an endpoint with the workspace's `cache: { remote: { url } }` or `VP_REMOTE_CACHE_URL`, and choose access with `--remote-cache=off|read|read-write` or `VP_REMOTE_CACHE`. The default is `read` with an endpoint and `off` without one. In `read-write` mode, `vp run` uploads the results of successful, cacheable tasks after caching them locally. A failed upload doesn't fail the task; the run summary shows a warning instead. Tasks can opt out with `cache: { remote: false }` ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755)). - **Fixed** On Windows, environment variable names used by `vp run` now match regardless of ASCII letter case. Assignments in task commands override earlier assignments and inherited variables spelled differently, and `FORCE_COLOR`, `VP_RUN_CONCURRENCY_LIMIT`, and variables requested through `@voidzero-dev/vite-task-client` are found under any spelling ([#747](https://github.com/voidzero-dev/vite-task/pull/747)). - **Changed** A task's cache settings now go inside `cache`, e.g. `cache: { env: ["NODE_ENV"], input: ["src/**"] }`; `cache: true` is the same as `cache: {}`. `env`, `untrackedEnv`, `input`, and `output` are no longer supported at the top level of a task ([#749](https://github.com/voidzero-dev/vite-task/pull/749)). - **Fixed** Cached tasks on macOS no longer intermittently fail with exit 2 and `oils I/O error (main): No such process` when a fast command finishes before the shell gets scheduled. The bundled shell that runs task commands is updated to Oils 0.38.0, which fixes this race ([#702](https://github.com/voidzero-dev/vite-task/issues/702), [#703](https://github.com/voidzero-dev/vite-task/pull/703)). diff --git a/Cargo.lock b/Cargo.lock index d9496e796..193c87da3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -227,13 +233,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -270,9 +282,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -490,6 +502,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -574,6 +613,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -659,6 +708,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cow-utils" version = "0.1.3" @@ -732,7 +797,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", @@ -754,6 +819,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1012,12 +1083,23 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "block2", "libc", "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1221,6 +1303,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1353,7 +1444,7 @@ name = "fspy_nostd" version = "0.0.0" dependencies = [ "atoi", - "bitflags 2.10.0", + "bitflags 2.13.2", "bstr", "libc", "linux-raw-sys 0.12.1", @@ -1430,7 +1521,7 @@ version = "0.0.0" dependencies = [ "allocator-api2", "assert2", - "bitflags 2.10.0", + "bitflags 2.13.2", "bstr", "bumpalo", "bytemuck", @@ -1456,7 +1547,7 @@ version = "0.0.0" dependencies = [ "allocator-api2", "anyhow", - "base64", + "base64 0.22.1", "bstr", "elf", "fspy_nostd", @@ -1649,6 +1740,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1702,6 +1804,45 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.11" @@ -1711,6 +1852,148 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec 1.15.1", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec 1.15.1", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1723,6 +2006,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec 1.15.1", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indenter" version = "0.3.4" @@ -1756,7 +2060,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -1798,6 +2102,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.17" @@ -1845,6 +2155,55 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1955,7 +2314,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "libc", "redox_syscall 0.7.0", ] @@ -1989,7 +2348,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", ] [[package]] @@ -2016,6 +2375,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -2113,6 +2478,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2169,7 +2550,7 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "ctor", "futures", "napi-build", @@ -2227,7 +2608,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -2239,7 +2620,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -2252,7 +2633,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -2281,7 +2662,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -2299,7 +2680,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", ] [[package]] @@ -2504,6 +2885,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2640,6 +3027,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.6" @@ -2845,6 +3238,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2952,7 +3354,7 @@ dependencies = [ name = "pty_terminal_test_client" version = "0.0.0" dependencies = [ - "base64", + "base64 0.22.1", "getrandom 0.4.2", "winapi", ] @@ -3042,7 +3444,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3094,7 +3496,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "hashbrown 0.16.1", "indoc", "instability", @@ -3133,7 +3535,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", ] [[package]] @@ -3142,7 +3544,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", ] [[package]] @@ -3206,25 +3608,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] -name = "rsqlite-vfs" -version = "0.1.0" +name = "reqwest" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] -name = "rusqlite" -version = "0.39.0" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "bitflags 2.10.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", "libsqlite3-sys", "smallvec 1.15.1", "sqlite-wasm-rs", @@ -3266,13 +3720,86 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3294,6 +3821,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3308,6 +3844,29 @@ dependencies = [ "libc", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "0.9.0" @@ -3339,6 +3898,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3512,6 +4081,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -3594,6 +4179,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stackalloc" version = "1.2.1" @@ -3650,7 +4241,7 @@ dependencies = [ name = "subprocess_test" version = "0.0.0" dependencies = [ - "base64", + "base64 0.22.1", "ctor", "fspy", "portable-pty", @@ -3658,6 +4249,12 @@ dependencies = [ "wincode", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "supports-color" version = "2.1.0" @@ -3699,6 +4296,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "syscalls" version = "0.8.1" @@ -3776,8 +4404,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.10.0", + "base64 0.22.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3903,6 +4531,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.49.0" @@ -3931,6 +4569,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4015,6 +4663,51 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4087,6 +4780,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ts-rs" version = "12.0.1" @@ -4141,6 +4840,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -4182,6 +4887,24 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-chars" version = "3.0.6" @@ -4191,6 +4914,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -4276,6 +5005,7 @@ dependencies = [ "vt_ipc_shared", "vt_path", "vt_plan", + "vt_remote_cache", "vt_select", "vt_server", "vt_str", @@ -4482,6 +5212,23 @@ dependencies = [ "which", ] +[[package]] +name = "vt_remote_cache" +version = "0.0.0" +dependencies = [ + "ciborium", + "reqwest", + "rustls", + "rustls-platform-verifier", + "serde", + "serde_bytes", + "tempfile", + "thiserror 2.0.18", + "tokio", + "vt_path", + "vt_str", +] + [[package]] name = "vt_select" version = "0.0.0" @@ -4603,6 +5350,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4640,6 +5396,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -4694,13 +5464,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver 1.0.27", @@ -4721,6 +5504,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -4731,6 +5524,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -4883,13 +5685,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -4901,6 +5712,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -4908,58 +5735,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -5060,7 +5935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -5090,6 +5965,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -5112,6 +5993,29 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -5132,6 +6036,66 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zmij" version = "1.0.20" diff --git a/Cargo.toml b/Cargo.toml index 9a5647905..88ce94243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ bumpalo = "3.17.0" bytemuck = "1.23.0" cargo-platform = "=0.3.2" cc = "1.2.39" +ciborium = "0.2.2" clap = "4.5.53" color-eyre = "0.6.5" compact_str = "0.9.0" @@ -125,11 +126,15 @@ ratatui = "0.30.0" rayon = "1.10.0" ref-cast = "1.0.24" regex = "1.11.3" +reqwest = { version = "0.13.5", default-features = false } rusqlite = "0.39.0" rustc-hash = "2.1.1" +rustls = { version = "0.23.44", default-features = false } +rustls-platform-verifier = "0.7.0" # SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0) seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" } serde = "1.0.219" +serde_bytes = "0.11.19" serde_json = "1.0.140" serde_norway = "0.9.42" sha2 = "0.11.0" @@ -166,6 +171,7 @@ vt_glob = { path = "crates/vt_glob" } vt_graph_ser = { path = "crates/vt_graph_ser" } vt_path = { path = "crates/vt_path" } vt_powershell = { path = "crates/vt_powershell" } +vt_remote_cache = { path = "crates/vt_remote_cache" } vt_select = { path = "crates/vt_select" } vt_shell = { path = "crates/vt_shell" } vt_str = { path = "crates/vt_str" } diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index 5705f55ea..e99b614e1 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -48,6 +48,7 @@ uuid = { workspace = true, features = ["v4"] } vt_casefold = { workspace = true } vt_glob = { workspace = true } vt_path = { workspace = true } +vt_remote_cache = { workspace = true } vt_select = { workspace = true } vt_str = { workspace = true } # Artifact dep under `[dependencies]` — not `[build-dependencies]`, where diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 86148cd5b..b72530b25 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -2,6 +2,7 @@ pub mod archive; pub mod display; +pub mod remote; mod validation; use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; @@ -17,7 +18,10 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use vt_graph::config::ResolvedGlobConfig; use vt_path::{AbsolutePath, RelativePathBuf}; -use vt_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}; +use vt_plan::{ + cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}, + remote_cache::{RemoteCacheAccess, ResolvedRemoteCacheConfig}, +}; use vt_str::Str; use wincode::{ SchemaRead, SchemaReadOwned, SchemaWrite, @@ -26,6 +30,7 @@ use wincode::{ io::{Reader, Writer}, }; +use self::remote::{RemoteClients, UploadError}; use super::execute::{ fingerprint::{PostRunFingerprint, TrackedEnvQuery}, pipe::StdOutput, @@ -140,6 +145,7 @@ pub struct CacheEntryValue { #[derive(Debug)] pub struct ExecutionCache { conn: Mutex, + remote_clients: RemoteClients, } #[derive(Debug, Clone, Serialize)] @@ -310,7 +316,7 @@ impl ExecutionCache { CREATE TABLE IF NOT EXISTS task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", )?; // Lock is released when lock_file is dropped - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { conn: Mutex::new(conn), remote_clients: RemoteClients::default() }) } #[tracing::instrument] @@ -364,13 +370,17 @@ impl ExecutionCache { /// If a previous entry exists for the same cache key with a different /// `output_archive`, the stale archive file in `cache_dir` is removed /// (best-effort) so it doesn't accumulate on disk. + /// + /// In `read-write` remote mode, the entry is then uploaded to the remote + /// cache. Returns `Ok(Err(_))` if the local update succeeded but the + /// upload failed. #[tracing::instrument(level = "debug", skip_all)] pub async fn update( &self, cache_metadata: &CacheMetadata, cache_value: CacheEntryValue, cache_dir: &AbsolutePath, - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { let execution_cache_key = &cache_metadata.execution_cache_key; let cache_key = CacheEntryKey::from_metadata(cache_metadata); @@ -389,7 +399,20 @@ impl ExecutionCache { self.upsert_cache_entry(&cache_key, &cache_value).await?; self.upsert_task_fingerprint(execution_cache_key, &cache_key).await?; - Ok(()) + + let Some(ResolvedRemoteCacheConfig { access: RemoteCacheAccess::ReadWrite, url }) = + &cache_metadata.remote_cache + else { + return Ok(Ok(())); + }; + let upload = self + .remote_clients + .upload(url, &cache_key, execution_cache_key, &cache_value, cache_dir) + .await; + if let Err(err) = &upload { + tracing::debug!(?err, "remote cache upload failed"); + } + Ok(upload) } } diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs new file mode 100644 index 000000000..28c706187 --- /dev/null +++ b/crates/vt/src/session/cache/remote.rs @@ -0,0 +1,97 @@ +//! The remote cache tier. After a local update in `read-write` mode, the entry +//! is uploaded to the remote cache as opaque bytes: +//! +//! | Field | Contents | +//! | --------------- | ------------------------------------- | +//! | `key` | Header + wincode(`CacheEntryKey`) | +//! | `secondary_key` | Header + wincode(`ExecutionCacheKey`) | +//! | `value` | wincode(`CacheEntryValue`) | +//! | blob | The `.tar.zst` output archive | +//! +//! Both keys start with a header containing the cache schema version and the +//! target OS and architecture. Platforms share an endpoint's namespace, but +//! their keys differ. + +use std::sync::{Arc, Mutex, PoisonError}; + +use rustc_hash::FxHashMap; +use vt_path::AbsolutePath; +use vt_plan::cache_metadata::ExecutionCacheKey; +use vt_remote_cache::{Client, Entry}; +use vt_str::Str; +use wincode::{ + SchemaWrite, + error::{WriteError, WriteResult}, +}; + +use super::{ + CACHE_SCHEMA_VERSION, CacheEntryKey, CacheEntryValue, TaskCacheConfig, serialize_cache, +}; + +/// Why an entry wasn't uploaded. The message names only the kind of failure, +/// so it's the same on every platform. +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + #[error(transparent)] + Remote(#[from] vt_remote_cache::Error), + #[error("failed to encode the cache entry")] + Encode(#[from] WriteError), +} + +/// Remote cache clients, each created when its endpoint is first used. +#[derive(Debug, Default)] +pub struct RemoteClients { + clients: Mutex, Arc>>, +} + +impl RemoteClients { + fn client(&self, endpoint: &Arc) -> Result, vt_remote_cache::Error> { + let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(client) = clients.get(endpoint) { + return Ok(Arc::clone(client)); + } + let client = Arc::new(Client::new(endpoint)?); + clients.insert(Arc::clone(endpoint), Arc::clone(&client)); + drop(clients); + Ok(client) + } + + /// Upload an entry that was just recorded locally, along with its output + /// archive in `cache_dir`. + pub(super) async fn upload( + &self, + endpoint: &Arc, + cache_key: &CacheEntryKey, + execution_cache_key: &ExecutionCacheKey, + cache_value: &CacheEntryValue, + cache_dir: &AbsolutePath, + ) -> Result<(), UploadError> { + let client = self.client(endpoint)?; + let key = encode_key(cache_key)?; + let secondary_key = encode_key(execution_cache_key)?; + let value = serialize_cache(cache_value)?; + let archive = cache_value.output_archive.as_ref().map(|name| cache_dir.join(name.as_str())); + let entry = Entry { key: &key, secondary_key: &secondary_key, value: &value }; + client.store(&entry, archive.as_deref()).await?; + Ok(()) + } +} + +#[derive(SchemaWrite)] +struct KeyHeader { + cache_schema_version: u32, + os: Str, + arch: Str, +} + +/// Encode a key with the header for this build. +fn encode_key>(key: &K) -> WriteResult> { + let header = KeyHeader { + cache_schema_version: CACHE_SCHEMA_VERSION, + os: Str::from(std::env::consts::OS), + arch: Str::from(std::env::consts::ARCH), + }; + let mut bytes = serialize_cache(&header)?; + bytes.extend(serialize_cache(key)?); + Ok(bytes) +} diff --git a/crates/vt/src/session/event.rs b/crates/vt/src/session/event.rs index b28b2f2d5..410f49a4b 100644 --- a/crates/vt/src/session/event.rs +++ b/crates/vt/src/session/event.rs @@ -3,7 +3,7 @@ use std::{process::ExitStatus, time::Duration}; use vt_path::RelativePathBuf; use vt_server::Error as IpcServerError; -use super::cache::CacheMiss; +use super::cache::{CacheMiss, remote::UploadError}; /// The cache operation that failed. #[derive(Debug)] @@ -110,7 +110,11 @@ pub enum CacheNotUpdatedReason { #[derive(Debug)] pub enum CacheUpdateStatus { /// Cache was successfully updated with new fingerprint and outputs - Updated, + Updated { + /// Why uploading the entry to the remote cache failed. `None` if the + /// upload succeeded or wasn't attempted. + upload_error: Option, + }, /// Cache was not updated (with reason). /// The reason is part of the `LeafExecutionReporter` trait contract — reporters /// can use it for detailed logging, even if current implementations don't. diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 31160c4ba..029c48f52 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -186,7 +186,7 @@ pub(super) async fn update_cache( output_archive, }; match cache.update(metadata, new_cache_value, cache_dir).await { - Ok(()) => (CacheUpdateStatus::Updated, None), + Ok(upload) => (CacheUpdateStatus::Updated { upload_error: upload.err() }, None), Err(err) => ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), Some(ExecutionError::Cache { kind: CacheErrorKind::Update, source: err }), diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 7bc36e5bb..428b776ae 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -120,6 +120,9 @@ pub enum SpawnOutcome { /// Set when a runner-aware tool called `disableCache()`, skipping /// cache update. tool_disabled_cache: bool, + /// Why uploading the entry to the remote cache failed, if it did. + /// The local cache was still updated. + upload_error: Option, }, /// Process exited with non-zero status. @@ -183,6 +186,13 @@ struct SummaryStats { total_saved: Duration, /// Display names of tasks that were not cached due to read-write overlap. input_modified_task_names: Vec, + /// Tasks whose upload to the remote cache failed. + upload_failures: Vec, +} + +struct UploadFailure { + task_name: Str, + reason: Str, } impl SummaryStats { @@ -195,6 +205,7 @@ impl SummaryStats { failed: 0, total_saved: Duration::ZERO, input_modified_task_names: Vec::new(), + upload_failures: Vec::new(), }; for task in tasks { @@ -220,6 +231,12 @@ impl SummaryStats { } SpawnOutcome::Success { .. } => {} } + if let SpawnOutcome::Success { upload_error: Some(reason), .. } = outcome { + stats.upload_failures.push(UploadFailure { + task_name: task.format_task_display(), + reason: reason.clone(), + }); + } } } } @@ -353,6 +370,12 @@ impl TaskResult { cache_update_status, CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete) ); + let upload_error = match cache_update_status { + CacheUpdateStatus::Updated { upload_error: Some(err) } => { + Some(vt_str::format!("{err}")) + } + _ => None, + }; match cache_status { CacheStatus::Hit { replayed_duration } => { @@ -369,6 +392,7 @@ impl TaskResult { ipc_server_error, tool_disabled_cache, tracking_incomplete, + upload_error, ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { @@ -383,6 +407,7 @@ impl TaskResult { ipc_server_error, tool_disabled_cache, tracking_incomplete, + upload_error, ), }, } @@ -390,6 +415,10 @@ impl TaskResult { } /// Build a [`SpawnOutcome`] from process exit status and optional pre-converted error. +#[expect( + clippy::too_many_arguments, + reason = "each cache update detail is extracted by the caller and passed through" +)] fn spawn_outcome_from_execution( exit_status: Option, saved_error: Option<&SavedExecutionError>, @@ -398,6 +427,7 @@ fn spawn_outcome_from_execution( ipc_server_error: Option, tool_disabled_cache: bool, tracking_incomplete: bool, + upload_error: Option, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran @@ -410,6 +440,7 @@ fn spawn_outcome_from_execution( ipc_server_error, tool_disabled_cache, tracking_incomplete, + upload_error, }, // Process exited with non-zero code (Some(status), _) => { @@ -431,6 +462,7 @@ fn spawn_outcome_from_execution( ipc_server_error: None, tool_disabled_cache: false, tracking_incomplete: false, + upload_error: None, }, } } @@ -641,6 +673,16 @@ impl TaskResult { } } + /// Why uploading the entry to the remote cache failed, if it did. + const fn upload_error(&self) -> Option<&Str> { + match self { + Self::Spawned { outcome: SpawnOutcome::Success { upload_error, .. }, .. } => { + upload_error.as_ref() + } + _ => None, + } + } + /// Optional error associated with this result. pub const fn error(&self) -> Option<&SavedExecutionError> { match self { @@ -801,6 +843,15 @@ pub fn format_full_summary(summary: &LastRunSummary) -> Vec { let cache_detail = task.result.format_cache_detail(); let _ = writeln!(buf, " {}", cache_detail.style(task.result.cache_detail_style())); + if let Some(reason) = task.result.upload_error() { + let _ = writeln!( + buf, + " {}", + vt_str::format!("⚠ Not uploaded to the remote cache: {reason}") + .style(Style::new().yellow()) + ); + } + // Error message if present if let Some(err) = task.result.error() { let msg = err.display_message(); @@ -848,8 +899,12 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V let is_single_task = summary.tasks.len() == 1; - // Single task + not cache hit + no input modification → no summary - if is_single_task && stats.cache_hits == 0 && stats.input_modified_task_names.is_empty() { + // Single task + not cache hit + no notice → no summary + if is_single_task + && stats.cache_hits == 0 + && stats.input_modified_task_names.is_empty() + && stats.upload_failures.is_empty() + { return Vec::new(); } @@ -908,14 +963,17 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V let _ = write!(buf, "."); } else { - // Single task, no cache hit — only shown when input_modified is non-empty + // Single task, no cache hit — only shown with a notice below let _ = write!(buf, "{}", run_label.as_str().style(Style::new().blue().bold())); } - // Inline input-modified notice before the --last-details hint + // Inline notices before the --last-details hint if !stats.input_modified_task_names.is_empty() { format_input_modified_notice(&mut buf, &stats.input_modified_task_names); } + if !stats.upload_failures.is_empty() { + format_upload_failed_notice(&mut buf, &stats.upload_failures); + } if show_last_details_hint { let last_details_cmd = vt_str::format!("`{program_name} run --last-details`"); @@ -946,10 +1004,78 @@ fn format_input_modified_notice(buf: &mut Vec, task_names: &[Str]) { } } +/// Write the "not uploaded to the remote cache" notice inline. The reason is +/// shown when all failed uploads share it. +fn format_upload_failed_notice(buf: &mut Vec, failures: &[UploadFailure]) { + let _ = write!(buf, " "); + + let first = &failures[0]; + let _ = write!(buf, "{}", first.task_name.as_str().style(Style::new().bold())); + let remaining = failures.len() - 1; + if remaining > 0 { + let _ = write!(buf, " (and {remaining} more)"); + } + + let _ = write!(buf, " not uploaded to the remote cache"); + if failures.iter().all(|failure| failure.reason == first.reason) { + let _ = write!(buf, ": {}", first.reason); + } + let _ = write!(buf, "."); +} + #[cfg(test)] mod tests { use super::*; + fn upload_failed_task(task_name: &str, reason: &str) -> TaskSummary { + TaskSummary { + package_name: Str::from("pkg"), + task_name: Str::from(task_name), + command: Str::from("build"), + cwd: Str::default(), + result: TaskResult::Spawned { + cache_status: SpawnedCacheStatus::Miss(SavedCacheMissReason::NotFound), + outcome: SpawnOutcome::Success { + infra_error: None, + input_modified_path: None, + fspy_unsupported: false, + ipc_server_error: None, + tracking_incomplete: false, + tool_disabled_cache: false, + upload_error: Some(Str::from(reason)), + }, + }, + } + } + + fn compact_summary(tasks: Vec) -> Str { + let bytes = format_compact_summary(&LastRunSummary { tasks, exit_code: 0 }, "vp"); + vt_str::format!("{}", anstream::adapter::strip_str(std::str::from_utf8(&bytes).unwrap())) + } + + #[test] + fn upload_failure_notice_shows_a_shared_reason() { + let summary = compact_summary(vec![ + upload_failed_task("a", "network error"), + upload_failed_task("b", "network error"), + ]); + assert_eq!( + summary.as_str(), + "---\nvp run: 0/2 cache hit (0%). pkg#a (and 1 more) not uploaded to the remote cache: \ + network error. (Run `vp run --last-details` for full details)\n" + ); + + let summary = compact_summary(vec![ + upload_failed_task("a", "network error"), + upload_failed_task("b", "HTTP status 500"), + ]); + assert_eq!( + summary.as_str(), + "---\nvp run: 0/2 cache hit (0%). pkg#a (and 1 more) not uploaded to the remote cache. \ + (Run `vp run --last-details` for full details)\n" + ); + } + #[test] fn output_forwarding_error_has_distinct_message() { let error = ExecutionError::ForwardTaskProcessOutput(anyhow::anyhow!( diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/package.json new file mode 100644 index 000000000..f4afb1054 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/package.json @@ -0,0 +1,4 @@ +{ + "name": "remote-cache", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml new file mode 100644 index 000000000..409032067 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml @@ -0,0 +1,118 @@ +# Cases that use the remote cache backend are ignored because it runs on +# Node.js. Windows is skipped because the PTY launcher cannot execute pnpm +# command shims. +[[e2e]] +name = "read_write" +cfg = "not(windows)" +ignore = true +steps = [ + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + ], comment = "A new execution is uploaded with one store request." }, + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + ], comment = "A local hit makes no requests." }, +] + +[[e2e]] +name = "read" +cfg = "not(windows)" +ignore = true +steps = [ + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + ] }, + [ + "vtt", + "write-file", + "src/a.txt", + "changed", + ], + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], comment = "An endpoint without a mode selects read. The task reruns without uploading." }, +] + +[[e2e]] +name = "invalid_endpoint" +steps = [ + { argv = [ + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + [ + "VP_REMOTE_CACHE_URL", + "cache.example/projects/test", + ], + ], comment = "The failed upload is a warning. The task succeeds." }, + [ + "vt", + "run", + "--last-details", + ], + { argv = [ + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + [ + "VP_REMOTE_CACHE_URL", + "cache.example/projects/test", + ], + ], comment = "The local cache was updated. Hits never upload." }, +] + +[[e2e]] +name = "unreachable_endpoint" +steps = [ + { argv = [ + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + [ + "VP_REMOTE_CACHE_URL", + "http://127.0.0.1:1/projects/test", + ], + ], comment = "Nothing listens on port 1. The failed upload is a warning. The task succeeds." }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md new file mode 100644 index 000000000..b8db65430 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md @@ -0,0 +1,42 @@ +# invalid_endpoint + +## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=cache.example/projects/test vt run build` + +The failed upload is a warning. The task succeeds. + +``` +$ vtt write-file dist/output.txt built + +--- +vt run: remote-cache#build not uploaded to the remote cache: invalid endpoint. (Run `vt run --last-details` for full details) +``` + +## `vt run --last-details` + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ + → Cache miss: no previous cache entry found + ⚠ Not uploaded to the remote cache: invalid endpoint +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=cache.example/projects/test vt run build` + +The local cache was updated. Hits never upload. + +``` +$ vtt write-file dist/output.txt built ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read.md new file mode 100644 index 000000000..5ade066f1 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read.md @@ -0,0 +1,22 @@ +# read + +## `VP_REMOTE_CACHE=read-write remote-cache-server vt run build` + +``` +$ vtt write-file dist/output.txt built + +[remote-cache] POST /store 200 +``` + +## `vtt write-file src/a.txt changed` + +``` +``` + +## `remote-cache-server vt run build` + +An endpoint without a mode selects read. The task reruns without uploading. + +``` +$ vtt write-file dist/output.txt built ○ cache miss: 'src/a.txt' modified, executing +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_write.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_write.md new file mode 100644 index 000000000..10fc112fd --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_write.md @@ -0,0 +1,22 @@ +# read_write + +## `VP_REMOTE_CACHE=read-write remote-cache-server vt run build` + +A new execution is uploaded with one store request. + +``` +$ vtt write-file dist/output.txt built + +[remote-cache] POST /store 200 +``` + +## `VP_REMOTE_CACHE=read-write remote-cache-server vt run build` + +A local hit makes no requests. + +``` +$ vtt write-file dist/output.txt built ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md new file mode 100644 index 000000000..6bdf3db41 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md @@ -0,0 +1,12 @@ +# unreachable_endpoint + +## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=http://127.0.0.1:1/projects/test vt run build` + +Nothing listens on port 1. The failed upload is a warning. The task succeeds. + +``` +$ vtt write-file dist/output.txt built + +--- +vt run: remote-cache#build not uploaded to the remote cache: network error. (Run `vt run --last-details` for full details) +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/src/a.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/src/a.txt new file mode 100644 index 000000000..789819226 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/src/a.txt @@ -0,0 +1 @@ +a diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/vite-task.json new file mode 100644 index 000000000..33a2ca322 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/vite-task.json @@ -0,0 +1,11 @@ +{ + "tasks": { + "build": { + "command": "vtt write-file dist/output.txt built", + "cache": { + "input": ["src/**"], + "output": ["dist/**"] + } + } + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md index f0004361e..ecf1cae0f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md @@ -4,6 +4,7 @@ ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Blob not found"} +[remote-cache] GET /blob/missing 404 ``` ## `vtt write-file archive.txt 'first archive'` @@ -15,18 +16,21 @@ ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'first', "blob_id": "1"}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} +[remote-cache] GET /blob/1 200 ``` ## `remote-cache-server cbor-http POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` @@ -35,18 +39,21 @@ Accept blob before metadata, with neither part supplying a filename. ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "2"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'second', "blob_id": "2"}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http GET /blob/2` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'second archive'} +[remote-cache] GET /blob/2 200 ``` ## `remote-cache-server cbor-http GET /blob/1` @@ -55,6 +62,7 @@ Previously returned IDs retain their original bytes. ``` {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} +[remote-cache] GET /blob/1 200 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` @@ -63,24 +71,28 @@ Omitting blob clears the association. ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'no archive', "blob_id": null}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "3"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'empty archive', "blob_id": "3"}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http GET /blob/3` @@ -89,4 +101,5 @@ An empty blob has an ID and downloads as an empty byte string. ``` {"status": 200, "content_type": "application/octet-stream", "body": ''} +[remote-cache] GET /blob/3 200 ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md index b03a65b48..5f9be001f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md @@ -4,24 +4,28 @@ ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "not_found"}} +[remote-cache] POST /fetch 200 not_found ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` @@ -30,6 +34,7 @@ The exact entry survives reassignment of S to B. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA', "blob_id": null}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` @@ -38,18 +43,21 @@ The previous fetch did not change S, so fallback returns B's entry. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B', "value": 'VB', "blob_id": null}} +[remote-cache] POST /fetch 200 fallback ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA2', "blob_id": null}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` @@ -58,16 +66,19 @@ An older association to A still resolves after A is replaced. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A', "value": 'VA2', "blob_id": null}} +[remote-cache] POST /fetch 200 fallback ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A', "value": 'VA2', "blob_id": null}} +[remote-cache] POST /fetch 200 fallback ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B', "value": 'VB', "blob_id": null}} +[remote-cache] POST /fetch 200 fallback ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md index 9d8e327d8..d294904b2 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md @@ -4,96 +4,112 @@ ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Route not found"} +[remote-cache] GET /missing 404 ``` ## `remote-cache-server cbor-http POST /fetch --data 'bad'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected application/cbor"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /fetch --data 'bad' --content-type application/cbor` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /fetch --cbor []` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid CBOR"} +[remote-cache] POST /fetch 400 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /store --cbor {}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected multipart/form-data"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-data 'blob='\''missing metadata'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Missing metadata"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key, value"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-cbor metadata={} --form-data unexpected='part'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --form-data 'metadata='\''wrong content type'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +[remote-cache] POST /store 400 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` @@ -102,12 +118,14 @@ Failed stores did not replace the value or blob. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": "1"}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'original archive'} +[remote-cache] GET /blob/1 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` @@ -116,4 +134,5 @@ Failed stores did not publish a secondary association. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "not_found"}} +[remote-cache] POST /fetch 200 not_found ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md index ab3d40053..b9bb2cd5e 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md @@ -4,34 +4,40 @@ ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": b64'AP+A', "blob_id": "1"}} +[remote-cache] POST /fetch 200 exact ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": b64'AP+A', "value": b64'AP+A', "blob_id": "1"}} +[remote-cache] POST /fetch 200 fallback ``` ## `remote-cache-server cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": b64'AP+A'} +[remote-cache] GET /blob/1 200 ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": '', "blob_id": null}} +[remote-cache] POST /fetch 200 exact ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/state_across_invocations.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/state_across_invocations.md index b73e06671..4eb0f38fd 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/state_across_invocations.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/state_across_invocations.md @@ -4,6 +4,7 @@ ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +[remote-cache] POST /store 200 ``` ## `remote-cache-server cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` @@ -12,6 +13,7 @@ A later invocation reads the stored entry and association. ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A', "value": 'VA', "blob_id": "1"}} +[remote-cache] POST /fetch 200 fallback ``` ## `remote-cache-server cbor-http POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''T'\'', "value": '\''VB'\''}' --form-data 'blob='\''second archive'\'''` @@ -20,6 +22,7 @@ Blob numbering continues across invocations. ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "2"}} +[remote-cache] POST /store 200 ``` ## `vtt write-file remote-cache/blobs/1 'replaced archive'` @@ -33,4 +36,5 @@ Each blob is a file named by its ID. ``` {"status": 200, "content_type": "application/octet-stream", "body": 'replaced archive'} +[remote-cache] GET /blob/1 200 ``` diff --git a/crates/vt_remote_cache/Cargo.toml b/crates/vt_remote_cache/Cargo.toml new file mode 100644 index 000000000..261d0e0b8 --- /dev/null +++ b/crates/vt_remote_cache/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "vt_remote_cache" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +ciborium = { workspace = true } +reqwest = { workspace = true, features = ["multipart", "rustls-no-provider", "stream"] } +rustls = { workspace = true, features = ["ring", "std", "tls12"] } +rustls-platform-verifier = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_bytes = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +vt_path = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } +vt_str = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/crates/vt_remote_cache/README.md b/crates/vt_remote_cache/README.md new file mode 100644 index 000000000..5896117f1 --- /dev/null +++ b/crates/vt_remote_cache/README.md @@ -0,0 +1,11 @@ +# vt_remote_cache + +Client for the [remote cache server API](https://github.com/voidzero-dev/vite-task/pull/713). It treats keys, values, and blobs as opaque bytes. Encoding task cache entries into them is up to the caller. + +`Client::new` takes the configured endpoint, which can include a namespace path, such as `https://cache.example.com/projects/my-project`. Each operation appends its route to that path, so a store goes to `https://cache.example.com/projects/my-project/store`. Endpoints that aren't HTTP or HTTPS URLs are rejected. + +`Client::store` sends an `Entry` as a multipart request: a CBOR `metadata` part with the key, secondary key, and value as byte strings, and an optional `blob` part streamed from a file. Only HTTP 200 counts as success. The response body isn't decoded. + +The HTTP client uses rustls with the ring crypto provider. HTTPS endpoints verify certificates with the operating system's verifier through `rustls-platform-verifier`, so certificates trusted by the system, including private ones, are accepted. HTTP endpoints don't load system certificates. Connections time out after 10 seconds. Reads time out after 60 seconds, and until the response headers arrive, that limit also covers sending the request. + +`Error` names the kind of failure: an invalid endpoint, a client that couldn't be created, a blob file that couldn't be read, a network error (including timeouts), or a status other than 200. Its messages contain no OS-specific details, so they can be shown to users as is. The underlying error is available as the source. diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs new file mode 100644 index 000000000..fa5efc62e --- /dev/null +++ b/crates/vt_remote_cache/src/lib.rs @@ -0,0 +1,281 @@ +//! Client for the remote cache server API. Keys, values, and blobs are opaque +//! bytes; the caller decides what they contain. + +use std::{sync::Arc, time::Duration}; + +use reqwest::{ + StatusCode, Url, + multipart::{Form, Part}, +}; +use rustls_platform_verifier::BuilderVerifierExt as _; +use serde::Serialize; +use vt_path::AbsolutePath; + +/// Time allowed to establish a connection, including the TLS handshake. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Time allowed for each read of a response. Until the response headers +/// arrive, it also bounds sending the request. +const READ_TIMEOUT: Duration = Duration::from_secs(60); + +/// A failed remote cache operation. The messages name only the kind of +/// failure, so they are the same on every platform. The underlying error, if +/// any, is the source. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The endpoint isn't an HTTP or HTTPS URL that can have a path. + #[error("invalid endpoint")] + InvalidEndpoint, + /// The HTTP client couldn't be created, for example because no root + /// certificates could be loaded. + #[error("failed to create the HTTP client")] + HttpClient(#[source] Box), + /// The blob file couldn't be opened. + #[error("failed to read the blob")] + ReadBlob(#[source] std::io::Error), + /// No complete response arrived, for example because the connection + /// failed or timed out. + #[error("network error")] + Network(#[source] reqwest::Error), + /// The server responded with a status other than 200. + #[error("HTTP status {}", .0.as_u16())] + Status(StatusCode), +} + +/// An entry to store. The server treats each field as opaque bytes. +#[derive(Debug, Serialize)] +pub struct Entry<'a> { + /// Identifies the entry. + #[serde(with = "serde_bytes")] + pub key: &'a [u8], + /// Associated with `key`, so fetches that match no key can fall back to + /// this entry. + #[serde(with = "serde_bytes")] + pub secondary_key: &'a [u8], + /// The stored value. + #[serde(with = "serde_bytes")] + pub value: &'a [u8], +} + +/// A client for one remote cache endpoint. +#[derive(Debug)] +pub struct Client { + http: reqwest::Client, + store_url: Url, +} + +impl Client { + /// Create a client for `endpoint`, a base URL that may include a + /// namespace path, such as `https://cache.example.com/projects/my-project`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidEndpoint`] if `endpoint` isn't a usable URL, or + /// [`Error::HttpClient`] if the HTTP client can't be created. + pub fn new(endpoint: &str) -> Result { + let endpoint = parse_endpoint(endpoint)?; + let store_url = route_url(&endpoint, "store")?; + let tls = tls_config(endpoint.scheme() == "https")?; + let http = reqwest::Client::builder() + .tls_backend_preconfigured(tls) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + .map_err(|err| Error::HttpClient(err.into()))?; + Ok(Self { http, store_url }) + } + + /// Store `entry` with `POST {endpoint}/store`, uploading the file at + /// `blob` as its blob. + /// + /// # Errors + /// + /// Returns an error if the blob file can't be opened, the request fails, + /// or the server responds with a status other than 200. + pub async fn store(&self, entry: &Entry<'_>, blob: Option<&AbsolutePath>) -> Result<(), Error> { + let mut form = Form::new().part("metadata", metadata_part(entry)); + if let Some(blob) = blob { + form = form.part("blob", blob_part(blob).await?); + } + let response = self + .http + .post(self.store_url.clone()) + .multipart(form) + .send() + .await + .map_err(Error::Network)?; + let status = response.status(); + if status != StatusCode::OK { + return Err(Error::Status(status)); + } + // The response's blob ID isn't needed. Read the body anyway, so the + // connection can be reused. + response.bytes().await.map_err(Error::Network)?; + Ok(()) + } +} + +fn parse_endpoint(endpoint: &str) -> Result { + let url = Url::parse(endpoint).map_err(|_| Error::InvalidEndpoint)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::InvalidEndpoint); + } + Ok(url) +} + +/// Append `route` to the endpoint's path, keeping its namespace path. +fn route_url(endpoint: &Url, route: &str) -> Result { + let mut url = endpoint.clone(); + url.path_segments_mut().map_err(|()| Error::InvalidEndpoint)?.pop_if_empty().push(route); + Ok(url) +} + +/// TLS settings using the ring crypto provider. HTTPS endpoints verify +/// certificates with the operating system's verifier. Plain HTTP endpoints +/// don't load the system's root certificates, so they work on systems that +/// have none. +fn tls_config(https: bool) -> Result { + let builder = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|err| Error::HttpClient(err.into()))?; + let builder = if https { + builder.with_platform_verifier().map_err(|err| Error::HttpClient(err.into()))? + } else { + builder.with_root_certificates(rustls::RootCertStore::empty()) + }; + Ok(builder.with_no_client_auth()) +} + +fn encode_metadata(entry: &Entry<'_>) -> Vec { + let mut bytes = Vec::new(); + ciborium::into_writer(entry, &mut bytes).expect("encoding byte strings into a Vec can't fail"); + bytes +} + +fn metadata_part(entry: &Entry<'_>) -> Part { + Part::bytes(encode_metadata(entry)).mime_str("application/cbor").expect("valid MIME type") +} + +async fn blob_part(path: &AbsolutePath) -> Result { + let file = tokio::fs::File::open(path).await.map_err(Error::ReadBlob)?; + let length = file.metadata().await.map_err(Error::ReadBlob)?.len(); + Ok(Part::stream_with_length(file, length) + .mime_str("application/octet-stream") + .expect("valid MIME type")) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read as _, Write as _}, + net::TcpListener, + }; + + use vt_path::AbsolutePathBuf; + + use super::*; + + fn store_url(endpoint: &str) -> Result { + route_url(&parse_endpoint(endpoint)?, "store") + } + + #[test] + fn store_url_keeps_the_namespace_path() { + for (endpoint, expected) in [ + ("http://cache.example/projects/test", "http://cache.example/projects/test/store"), + ("http://cache.example/projects/test/", "http://cache.example/projects/test/store"), + ("https://cache.example", "https://cache.example/store"), + ("https://cache.example/ns?token=a", "https://cache.example/ns/store?token=a"), + ] { + assert_eq!(store_url(endpoint).unwrap().as_str(), expected, "{endpoint}"); + } + } + + #[test] + fn rejects_endpoints_that_are_not_http_urls() { + for endpoint in ["cache.example/projects/test", "ftp://cache.example", "mailto:a@b.example"] + { + assert!(matches!(Client::new(endpoint), Err(Error::InvalidEndpoint)), "{endpoint}"); + } + } + + #[test] + fn metadata_is_a_cbor_map_of_byte_strings() { + let entry = Entry { key: b"k", secondary_key: b"", value: &[0x00, 0xff] }; + let mut expected = vec![0xa3]; + expected.extend(b"\x63key\x41k"); + expected.extend(b"\x6dsecondary_key\x40"); + expected.extend(b"\x65value\x42\x00\xff"); + assert_eq!(encode_metadata(&entry), expected); + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|window| window == needle) + } + + /// Accept one HTTP request, respond with `status_line`, and return the + /// raw request. + fn serve_once(listener: &TcpListener, status_line: &str) -> Vec { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buf = [0; 4096]; + let header_end = loop { + let n = stream.read(&mut buf).unwrap(); + assert_ne!(n, 0, "connection closed before the request headers ended"); + request.extend_from_slice(&buf[..n]); + if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break pos + 4; + } + }; + let content_length: usize = std::str::from_utf8(&request[..header_end]) + .unwrap() + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap()) + }) + .expect("request has a content length"); + while request.len() < header_end + content_length { + let n = stream.read(&mut buf).unwrap(); + assert_ne!(n, 0, "connection closed before the request body ended"); + request.extend_from_slice(&buf[..n]); + } + let response = vt_str::format!("{status_line}\r\ncontent-length: 0\r\n\r\n"); + stream.write_all(response.as_bytes()).unwrap(); + request + } + + #[tokio::test] + async fn store_posts_metadata_and_blob_parts() { + let dir = tempfile::tempdir().unwrap(); + let blob = AbsolutePathBuf::new(dir.path().join("archive.tar.zst")).unwrap(); + std::fs::write(blob.as_path(), b"archive bytes").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = + std::thread::spawn(move || serve_once(&listener, "HTTP/1.1 500 Internal Server Error")); + + let client = + Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); + let entry = Entry { key: b"k", secondary_key: b"s", value: b"v" }; + let result = client.store(&entry, Some(&blob)).await; + assert!(matches!(result, Err(Error::Status(StatusCode::INTERNAL_SERVER_ERROR)))); + assert_eq!(result.unwrap_err().to_string(), "HTTP status 500"); + + let request = server.join().unwrap(); + assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); + assert!(contains(&request, b"content-type: multipart/form-data; boundary=")); + let metadata = [ + b"name=\"metadata\"\r\nContent-Type: application/cbor\r\n\r\n".as_slice(), + &encode_metadata(&entry), + b"\r\n", + ] + .concat(); + assert!(contains(&request, &metadata)); + let blob = + b"name=\"blob\"\r\nContent-Type: application/octet-stream\r\n\r\narchive bytes\r\n"; + assert!(contains(&request, blob)); + } +} diff --git a/deny.toml b/deny.toml index 4644e0a61..bf028f611 100644 --- a/deny.toml +++ b/deny.toml @@ -70,6 +70,8 @@ exceptions = [ # Each entry is the crate and version constraint, and its specific allow # list # { allow = ["Zlib"], name = "adler32", version = "*" }, + # Mozilla's root certificates, used by rustls-platform-verifier on wasm32 only. + { allow = ["CDLA-Permissive-2.0"], name = "webpki-root-certs" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/packages/tools/README.md b/packages/tools/README.md index d31b82665..f5489c440 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -11,6 +11,14 @@ remote-cache-server cbor-http POST /fetch --cbor "{\"key\": 'A', \"secondary_key `remote-cache-server COMMAND [ARGS...]` starts the backend on a free loopback port and runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint, `http://127.0.0.1:/projects/test`. The fixed base path gives every endpoint a namespace path. The wrapper takes no options and passes all arguments to the command unchanged. The command inherits stdio. When it exits, the server stops and the wrapper exits with the command's exit code. +After the command exits, the wrapper prints one line to stderr for each request it served, in the order of the responses. Each line has the method, the path below the base path, and the status. Fetch responses add their kind: + +```text +[remote-cache] POST /fetch 200 not_found +[remote-cache] POST /store 200 +[remote-cache] GET /blob/1 200 +``` + State persists in `remote-cache/` in the current directory, so consecutive commands share it. Each E2E case has its own directory and state. `state.json` holds the entries, associations, and next blob ID, with keys and values hex-encoded. Each blob is a file in `remote-cache/blobs/` named by its blob ID. Blob IDs are sequential strings and continue across invocations, keeping snapshots deterministic. The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache server API](https://github.com/voidzero-dev/vite-task/pull/713). Keys, values, and blobs are opaque bytes without length limits. There is no authentication. diff --git a/packages/tools/src/remote-cache/cli.ts b/packages/tools/src/remote-cache/cli.ts index fa4125ee8..b118e8b18 100755 --- a/packages/tools/src/remote-cache/cli.ts +++ b/packages/tools/src/remote-cache/cli.ts @@ -7,7 +7,12 @@ import { createCacheServer } from './server.ts'; const basePath = '/projects/test'; const [command, ...args] = process.argv.slice(2); -const server = createCacheServer({ basePath, directory: 'remote-cache' }); +const requests: string[] = []; +const server = createCacheServer({ + basePath, + directory: 'remote-cache', + logRequest: (line) => requests.push(line), +}); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const { port } = server.address() as AddressInfo; @@ -17,4 +22,5 @@ const child = spawn(command!, args, { }); const [code] = (await once(child, 'exit')) as [number | null]; server.close(); +for (const line of requests) console.error(`[remote-cache] ${line}`); process.exitCode = code; diff --git a/packages/tools/src/remote-cache/server.ts b/packages/tools/src/remote-cache/server.ts index f4c7a551b..88a1b6f14 100644 --- a/packages/tools/src/remote-cache/server.ts +++ b/packages/tools/src/remote-cache/server.ts @@ -97,14 +97,18 @@ function cbor(response: ServerResponse, value: unknown): void { /** * A test backend that keeps its state in `directory`: entries and associations * in `state.json`, and each blob in `blobs/` under its ID. Keys, values, and - * blobs remain opaque bytes. + * blobs remain opaque bytes. After each response, `logRequest` receives a line + * with the method, the route below `basePath`, the status, and for fetch + * responses, the kind. */ export function createCacheServer({ basePath, directory, + logRequest, }: { basePath: string; directory: string; + logRequest: (line: string) => void; }) { const stateFile = join(directory, 'state.json'); const blobDirectory = join(directory, 'blobs'); @@ -115,14 +119,18 @@ export function createCacheServer({ const associations = new Map(Object.entries(state.associations)); let nextBlobId = state.next_blob_id; - async function handle(request: IncomingMessage, response: ServerResponse): Promise { - const path = new URL(request.url ?? '/', 'http://localhost').pathname; + /** Respond to `request`, returning the kind of a fetch response. */ + async function handle( + request: IncomingMessage, + response: ServerResponse, + path: string, + ): Promise { if (request.method === 'GET' && path.startsWith(`${basePath}/blob/`)) { const file = join(blobDirectory, path.slice(`${basePath}/blob/`.length)); if (!existsSync(file)) throw new RequestError(404, 'Blob not found'); response.writeHead(200, { 'content-type': 'application/octet-stream' }); response.end(readFileSync(file)); - return; + return undefined; } if (request.method !== 'POST' || ![`${basePath}/fetch`, `${basePath}/store`].includes(path)) { throw new RequestError(404, 'Route not found'); @@ -140,17 +148,19 @@ export function createCacheServer({ const fallback = associatedKey === undefined ? undefined : entries.get(associatedKey); if (exact) { cbor(response, { kind: 'exact', value: fromHex(exact.value), blob_id: exact.blob_id }); - } else if (fallback) { + return 'exact'; + } + if (fallback) { cbor(response, { kind: 'fallback', key: fromHex(associatedKey!), value: fromHex(fallback.value), blob_id: fallback.blob_id, }); - } else { - cbor(response, { kind: 'not_found' }); + return 'fallback'; } - return; + cbor(response, { kind: 'not_found' }); + return 'not_found'; } if (mediaType(contentType) !== 'multipart/form-data') { @@ -179,10 +189,16 @@ export function createCacheServer({ }; writeFileSync(stateFile, `${JSON.stringify(saved, null, 2)}\n`); cbor(response, { blob_id: blobId }); + return undefined; } return createServer((request, response) => { - void handle(request, response).catch((error: unknown) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname; + const log = (kind?: string) => { + const parts = [request.method, path.slice(basePath.length), response.statusCode, kind]; + logRequest(parts.filter((part) => part !== undefined).join(' ')); + }; + void handle(request, response, path).then(log, (error: unknown) => { const known = error instanceof RequestError; if (!known) console.error(error); response.writeHead(known ? error.status : 500, { @@ -190,6 +206,7 @@ export function createCacheServer({ }); response.end(known ? error.message : 'Internal server error'); request.resume(); + log(); }); }); } From 8e8c8fb431dc1820ab190fd0b614c3d03a6d5816 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 02:38:28 +0800 Subject: [PATCH 2/8] test(cache): cover store without a blob Co-authored-by: Claude Opus 5.5 --- crates/vt_remote_cache/src/lib.rs | 51 ++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs index fa5efc62e..71b472bc3 100644 --- a/crates/vt_remote_cache/src/lib.rs +++ b/crates/vt_remote_cache/src/lib.rs @@ -215,9 +215,9 @@ mod tests { haystack.windows(needle.len()).any(|window| window == needle) } - /// Accept one HTTP request, respond with `status_line`, and return the - /// raw request. - fn serve_once(listener: &TcpListener, status_line: &str) -> Vec { + /// Accept one HTTP request, respond with `status_line` and `body`, and + /// return the raw request. + fn serve_once(listener: &TcpListener, status_line: &str, body: &[u8]) -> Vec { let (mut stream, _) = listener.accept().unwrap(); let mut request = Vec::new(); let mut buf = [0; 4096]; @@ -242,11 +242,20 @@ mod tests { assert_ne!(n, 0, "connection closed before the request body ended"); request.extend_from_slice(&buf[..n]); } - let response = vt_str::format!("{status_line}\r\ncontent-length: 0\r\n\r\n"); - stream.write_all(response.as_bytes()).unwrap(); + let headers = vt_str::format!("{status_line}\r\ncontent-length: {}\r\n\r\n", body.len()); + stream.write_all(&[headers.as_bytes(), body].concat()).unwrap(); request } + fn metadata_part_bytes(entry: &Entry<'_>) -> Vec { + [ + b"name=\"metadata\"\r\nContent-Type: application/cbor\r\n\r\n".as_slice(), + &encode_metadata(entry), + b"\r\n", + ] + .concat() + } + #[tokio::test] async fn store_posts_metadata_and_blob_parts() { let dir = tempfile::tempdir().unwrap(); @@ -254,8 +263,9 @@ mod tests { std::fs::write(blob.as_path(), b"archive bytes").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); - let server = - std::thread::spawn(move || serve_once(&listener, "HTTP/1.1 500 Internal Server Error")); + let server = std::thread::spawn(move || { + serve_once(&listener, "HTTP/1.1 500 Internal Server Error", b"") + }); let client = Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); @@ -267,15 +277,28 @@ mod tests { let request = server.join().unwrap(); assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); assert!(contains(&request, b"content-type: multipart/form-data; boundary=")); - let metadata = [ - b"name=\"metadata\"\r\nContent-Type: application/cbor\r\n\r\n".as_slice(), - &encode_metadata(&entry), - b"\r\n", - ] - .concat(); - assert!(contains(&request, &metadata)); + assert!(contains(&request, &metadata_part_bytes(&entry))); let blob = b"name=\"blob\"\r\nContent-Type: application/octet-stream\r\n\r\narchive bytes\r\n"; assert!(contains(&request, blob)); } + + #[tokio::test] + async fn store_without_a_blob_sends_only_metadata() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + // `0xff` can't start a CBOR item, so this body doesn't decode. + let server = + std::thread::spawn(move || serve_once(&listener, "HTTP/1.1 200 OK", b"\xffnot cbor")); + + let client = + Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); + let entry = Entry { key: b"k", secondary_key: b"s", value: b"v" }; + client.store(&entry, None).await.unwrap(); + + let request = server.join().unwrap(); + assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); + assert!(contains(&request, &metadata_part_bytes(&entry))); + assert!(!contains(&request, b"name=\"blob\"")); + } } From f0bd51f39d62fcd78160dbce0a475717d1ab7d44 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 11:03:11 +0800 Subject: [PATCH 3/8] refactor(cache): pass store fields directly instead of a serializable entry `Client::store` takes the key, secondary key, and value as byte slices, like `fetch` will. The CBOR `metadata` part is encoded from a private struct, so the public API no longer doubles as the wire format. Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/remote.rs | 5 +-- crates/vt_remote_cache/README.md | 2 +- crates/vt_remote_cache/src/lib.rs | 62 +++++++++++++++------------ 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index 28c706187..9223a355b 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use rustc_hash::FxHashMap; use vt_path::AbsolutePath; use vt_plan::cache_metadata::ExecutionCacheKey; -use vt_remote_cache::{Client, Entry}; +use vt_remote_cache::Client; use vt_str::Str; use wincode::{ SchemaWrite, @@ -71,8 +71,7 @@ impl RemoteClients { let secondary_key = encode_key(execution_cache_key)?; let value = serialize_cache(cache_value)?; let archive = cache_value.output_archive.as_ref().map(|name| cache_dir.join(name.as_str())); - let entry = Entry { key: &key, secondary_key: &secondary_key, value: &value }; - client.store(&entry, archive.as_deref()).await?; + client.store(&key, &secondary_key, &value, archive.as_deref()).await?; Ok(()) } } diff --git a/crates/vt_remote_cache/README.md b/crates/vt_remote_cache/README.md index 5896117f1..bb2520669 100644 --- a/crates/vt_remote_cache/README.md +++ b/crates/vt_remote_cache/README.md @@ -4,7 +4,7 @@ Client for the [remote cache server API](https://github.com/voidzero-dev/vite-ta `Client::new` takes the configured endpoint, which can include a namespace path, such as `https://cache.example.com/projects/my-project`. Each operation appends its route to that path, so a store goes to `https://cache.example.com/projects/my-project/store`. Endpoints that aren't HTTP or HTTPS URLs are rejected. -`Client::store` sends an `Entry` as a multipart request: a CBOR `metadata` part with the key, secondary key, and value as byte strings, and an optional `blob` part streamed from a file. Only HTTP 200 counts as success. The response body isn't decoded. +`Client::store` sends a multipart request: a CBOR `metadata` part with the key, secondary key, and value as byte strings, and an optional `blob` part streamed from a file. Only HTTP 200 counts as success. The response body isn't decoded. The HTTP client uses rustls with the ring crypto provider. HTTPS endpoints verify certificates with the operating system's verifier through `rustls-platform-verifier`, so certificates trusted by the system, including private ones, are accepted. HTTP endpoints don't load system certificates. Connections time out after 10 seconds. Reads time out after 60 seconds, and until the response headers arrive, that limit also covers sending the request. diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs index 71b472bc3..33aa212bf 100644 --- a/crates/vt_remote_cache/src/lib.rs +++ b/crates/vt_remote_cache/src/lib.rs @@ -42,19 +42,15 @@ pub enum Error { Status(StatusCode), } -/// An entry to store. The server treats each field as opaque bytes. -#[derive(Debug, Serialize)] -pub struct Entry<'a> { - /// Identifies the entry. +/// The `metadata` part of a store request. +#[derive(Serialize)] +struct StoreMetadata<'a> { #[serde(with = "serde_bytes")] - pub key: &'a [u8], - /// Associated with `key`, so fetches that match no key can fall back to - /// this entry. + key: &'a [u8], #[serde(with = "serde_bytes")] - pub secondary_key: &'a [u8], - /// The stored value. + secondary_key: &'a [u8], #[serde(with = "serde_bytes")] - pub value: &'a [u8], + value: &'a [u8], } /// A client for one remote cache endpoint. @@ -85,15 +81,23 @@ impl Client { Ok(Self { http, store_url }) } - /// Store `entry` with `POST {endpoint}/store`, uploading the file at - /// `blob` as its blob. + /// Store `value` under `key` with `POST {endpoint}/store`, uploading the + /// file at `blob` as its blob. Fetches that match no key fall back to this + /// entry through `secondary_key`. /// /// # Errors /// /// Returns an error if the blob file can't be opened, the request fails, /// or the server responds with a status other than 200. - pub async fn store(&self, entry: &Entry<'_>, blob: Option<&AbsolutePath>) -> Result<(), Error> { - let mut form = Form::new().part("metadata", metadata_part(entry)); + pub async fn store( + &self, + key: &[u8], + secondary_key: &[u8], + value: &[u8], + blob: Option<&AbsolutePath>, + ) -> Result<(), Error> { + let metadata = StoreMetadata { key, secondary_key, value }; + let mut form = Form::new().part("metadata", metadata_part(&metadata)); if let Some(blob) = blob { form = form.part("blob", blob_part(blob).await?); } @@ -148,14 +152,15 @@ fn tls_config(https: bool) -> Result { Ok(builder.with_no_client_auth()) } -fn encode_metadata(entry: &Entry<'_>) -> Vec { +fn encode_metadata(metadata: &StoreMetadata<'_>) -> Vec { let mut bytes = Vec::new(); - ciborium::into_writer(entry, &mut bytes).expect("encoding byte strings into a Vec can't fail"); + ciborium::into_writer(metadata, &mut bytes) + .expect("encoding byte strings into a Vec can't fail"); bytes } -fn metadata_part(entry: &Entry<'_>) -> Part { - Part::bytes(encode_metadata(entry)).mime_str("application/cbor").expect("valid MIME type") +fn metadata_part(metadata: &StoreMetadata<'_>) -> Part { + Part::bytes(encode_metadata(metadata)).mime_str("application/cbor").expect("valid MIME type") } async fn blob_part(path: &AbsolutePath) -> Result { @@ -203,12 +208,12 @@ mod tests { #[test] fn metadata_is_a_cbor_map_of_byte_strings() { - let entry = Entry { key: b"k", secondary_key: b"", value: &[0x00, 0xff] }; + let metadata = StoreMetadata { key: b"k", secondary_key: b"", value: &[0x00, 0xff] }; let mut expected = vec![0xa3]; expected.extend(b"\x63key\x41k"); expected.extend(b"\x6dsecondary_key\x40"); expected.extend(b"\x65value\x42\x00\xff"); - assert_eq!(encode_metadata(&entry), expected); + assert_eq!(encode_metadata(&metadata), expected); } fn contains(haystack: &[u8], needle: &[u8]) -> bool { @@ -247,10 +252,13 @@ mod tests { request } - fn metadata_part_bytes(entry: &Entry<'_>) -> Vec { + /// The `metadata` part of a store request for key `k`, secondary key `s`, + /// and value `v`. + fn metadata_part_bytes() -> Vec { + let metadata = StoreMetadata { key: b"k", secondary_key: b"s", value: b"v" }; [ b"name=\"metadata\"\r\nContent-Type: application/cbor\r\n\r\n".as_slice(), - &encode_metadata(entry), + &encode_metadata(&metadata), b"\r\n", ] .concat() @@ -269,15 +277,14 @@ mod tests { let client = Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); - let entry = Entry { key: b"k", secondary_key: b"s", value: b"v" }; - let result = client.store(&entry, Some(&blob)).await; + let result = client.store(b"k", b"s", b"v", Some(&blob)).await; assert!(matches!(result, Err(Error::Status(StatusCode::INTERNAL_SERVER_ERROR)))); assert_eq!(result.unwrap_err().to_string(), "HTTP status 500"); let request = server.join().unwrap(); assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); assert!(contains(&request, b"content-type: multipart/form-data; boundary=")); - assert!(contains(&request, &metadata_part_bytes(&entry))); + assert!(contains(&request, &metadata_part_bytes())); let blob = b"name=\"blob\"\r\nContent-Type: application/octet-stream\r\n\r\narchive bytes\r\n"; assert!(contains(&request, blob)); @@ -293,12 +300,11 @@ mod tests { let client = Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); - let entry = Entry { key: b"k", secondary_key: b"s", value: b"v" }; - client.store(&entry, None).await.unwrap(); + client.store(b"k", b"s", b"v", None).await.unwrap(); let request = server.join().unwrap(); assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); - assert!(contains(&request, &metadata_part_bytes(&entry))); + assert!(contains(&request, &metadata_part_bytes())); assert!(!contains(&request, b"name=\"blob\"")); } } From 755829df819e6f97abd229210033d041e57226cf Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 11:04:58 +0800 Subject: [PATCH 4/8] refactor(cache): let reqwest configure TLS for the remote cache Install ring as the process's default crypto provider, as vite-plus does, and let reqwest build the TLS config with the platform verifier. This drops the preconfigured rustls config and the direct `rustls-platform-verifier` dependency. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 - Cargo.toml | 1 - crates/vt_remote_cache/Cargo.toml | 3 +-- crates/vt_remote_cache/README.md | 2 +- crates/vt_remote_cache/src/lib.rs | 31 +++++++------------------------ 5 files changed, 9 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 193c87da3..b56c15954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5219,7 +5219,6 @@ dependencies = [ "ciborium", "reqwest", "rustls", - "rustls-platform-verifier", "serde", "serde_bytes", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 88ce94243..f7b922997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,7 +130,6 @@ reqwest = { version = "0.13.5", default-features = false } rusqlite = "0.39.0" rustc-hash = "2.1.1" rustls = { version = "0.23.44", default-features = false } -rustls-platform-verifier = "0.7.0" # SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0) seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" } serde = "1.0.219" diff --git a/crates/vt_remote_cache/Cargo.toml b/crates/vt_remote_cache/Cargo.toml index 261d0e0b8..b70452488 100644 --- a/crates/vt_remote_cache/Cargo.toml +++ b/crates/vt_remote_cache/Cargo.toml @@ -10,8 +10,7 @@ rust-version.workspace = true [dependencies] ciborium = { workspace = true } reqwest = { workspace = true, features = ["multipart", "rustls-no-provider", "stream"] } -rustls = { workspace = true, features = ["ring", "std", "tls12"] } -rustls-platform-verifier = { workspace = true } +rustls = { workspace = true, features = ["ring", "std"] } serde = { workspace = true, features = ["derive"] } serde_bytes = { workspace = true } thiserror = { workspace = true } diff --git a/crates/vt_remote_cache/README.md b/crates/vt_remote_cache/README.md index bb2520669..d17c44c38 100644 --- a/crates/vt_remote_cache/README.md +++ b/crates/vt_remote_cache/README.md @@ -6,6 +6,6 @@ Client for the [remote cache server API](https://github.com/voidzero-dev/vite-ta `Client::store` sends a multipart request: a CBOR `metadata` part with the key, secondary key, and value as byte strings, and an optional `blob` part streamed from a file. Only HTTP 200 counts as success. The response body isn't decoded. -The HTTP client uses rustls with the ring crypto provider. HTTPS endpoints verify certificates with the operating system's verifier through `rustls-platform-verifier`, so certificates trusted by the system, including private ones, are accepted. HTTP endpoints don't load system certificates. Connections time out after 10 seconds. Reads time out after 60 seconds, and until the response headers arrive, that limit also covers sending the request. +reqwest configures TLS. It uses the process's default rustls crypto provider, which the client installs as ring unless one is already installed, and verifies certificates with the operating system's verifier. Connections time out after 10 seconds. Reads time out after 60 seconds, and until the response headers arrive, that limit also covers sending the request. `Error` names the kind of failure: an invalid endpoint, a client that couldn't be created, a blob file that couldn't be read, a network error (including timeouts), or a status other than 200. Its messages contain no OS-specific details, so they can be shown to users as is. The underlying error is available as the source. diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs index 33aa212bf..e62537759 100644 --- a/crates/vt_remote_cache/src/lib.rs +++ b/crates/vt_remote_cache/src/lib.rs @@ -1,13 +1,12 @@ //! Client for the remote cache server API. Keys, values, and blobs are opaque //! bytes; the caller decides what they contain. -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use reqwest::{ StatusCode, Url, multipart::{Form, Part}, }; -use rustls_platform_verifier::BuilderVerifierExt as _; use serde::Serialize; use vt_path::AbsolutePath; @@ -29,7 +28,7 @@ pub enum Error { /// The HTTP client couldn't be created, for example because no root /// certificates could be loaded. #[error("failed to create the HTTP client")] - HttpClient(#[source] Box), + HttpClient(#[source] reqwest::Error), /// The blob file couldn't be opened. #[error("failed to read the blob")] ReadBlob(#[source] std::io::Error), @@ -71,13 +70,15 @@ impl Client { pub fn new(endpoint: &str) -> Result { let endpoint = parse_endpoint(endpoint)?; let store_url = route_url(&endpoint, "store")?; - let tls = tls_config(endpoint.scheme() == "https")?; + // reqwest configures TLS with the process's default crypto provider. + // Installing fails if one is already installed; vite-plus installs + // ring too. + let _ = rustls::crypto::ring::default_provider().install_default(); let http = reqwest::Client::builder() - .tls_backend_preconfigured(tls) .connect_timeout(CONNECT_TIMEOUT) .read_timeout(READ_TIMEOUT) .build() - .map_err(|err| Error::HttpClient(err.into()))?; + .map_err(Error::HttpClient)?; Ok(Self { http, store_url }) } @@ -134,24 +135,6 @@ fn route_url(endpoint: &Url, route: &str) -> Result { Ok(url) } -/// TLS settings using the ring crypto provider. HTTPS endpoints verify -/// certificates with the operating system's verifier. Plain HTTP endpoints -/// don't load the system's root certificates, so they work on systems that -/// have none. -fn tls_config(https: bool) -> Result { - let builder = rustls::ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions() - .map_err(|err| Error::HttpClient(err.into()))?; - let builder = if https { - builder.with_platform_verifier().map_err(|err| Error::HttpClient(err.into()))? - } else { - builder.with_root_certificates(rustls::RootCertStore::empty()) - }; - Ok(builder.with_no_client_auth()) -} - fn encode_metadata(metadata: &StoreMetadata<'_>) -> Vec { let mut bytes = Vec::new(); ciborium::into_writer(metadata, &mut bytes) From d3277ab35b4205457c99c01c46710c16b66b8def Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 12:02:15 +0800 Subject: [PATCH 5/8] test(cache): use port 0 for the unreachable remote cache endpoint Nothing can listen on port 0, so connecting to it always fails, whereas any process could listen on port 1. Co-authored-by: Claude Opus 5.5 --- .../tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml | 4 ++-- .../fixtures/remote_cache/snapshots/unreachable_endpoint.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml index 409032067..395548fcb 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml @@ -112,7 +112,7 @@ steps = [ ], [ "VP_REMOTE_CACHE_URL", - "http://127.0.0.1:1/projects/test", + "http://127.0.0.1:0/projects/test", ], - ], comment = "Nothing listens on port 1. The failed upload is a warning. The task succeeds." }, + ], comment = "Nothing can listen on port 0. The failed upload is a warning. The task succeeds." }, ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md index 6bdf3db41..fa7ea2306 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md @@ -1,8 +1,8 @@ # unreachable_endpoint -## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=http://127.0.0.1:1/projects/test vt run build` +## `VP_REMOTE_CACHE=read-write VP_REMOTE_CACHE_URL=http://127.0.0.1:0/projects/test vt run build` -Nothing listens on port 1. The failed upload is a warning. The task succeeds. +Nothing can listen on port 0. The failed upload is a warning. The task succeeds. ``` $ vtt write-file dist/output.txt built From d13f837dc58f383a5bb2125ce2bda47c32b9f9cb Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 25 Sep 2026 12:05:43 +0800 Subject: [PATCH 6/8] feat(cache): show remote cache failure details in `--last-details` The run summary still names only the kind of upload failure, which is the same on every platform. `--last-details` adds the underlying errors, such as the connection error behind a network error, the parse error for an endpoint that isn't a URL, or the message in an error response's body. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/vt/src/session/cache/remote.rs | 45 ++++++++++++++- crates/vt/src/session/reporter/summary.rs | 24 ++++---- .../fixtures/remote_cache/snapshots.toml | 4 +- .../snapshots/invalid_endpoint.md | 4 +- crates/vt_remote_cache/Cargo.toml | 3 +- crates/vt_remote_cache/README.md | 2 +- crates/vt_remote_cache/src/lib.rs | 56 +++++++++++++------ 9 files changed, 104 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b56c15954..867cf2389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5224,6 +5224,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "url", "vt_path", "vt_str", ] diff --git a/Cargo.toml b/Cargo.toml index f7b922997..fb0603fc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ assert2 = "0.4.0" assertables = "10.0.0" async-trait = "0.1.89" base64 = "0.22.1" +url = "2.5.8" wincode = "0.6.0" bindgen = "0.72.1" bitflags = "2.10.0" diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index 9223a355b..ba4a3d8d4 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -12,9 +12,13 @@ //! target OS and architecture. Platforms share an endpoint's namespace, but //! their keys differ. -use std::sync::{Arc, Mutex, PoisonError}; +use std::{ + error::Error, + sync::{Arc, Mutex, PoisonError}, +}; use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; use vt_path::AbsolutePath; use vt_plan::cache_metadata::ExecutionCacheKey; use vt_remote_cache::Client; @@ -29,7 +33,7 @@ use super::{ }; /// Why an entry wasn't uploaded. The message names only the kind of failure, -/// so it's the same on every platform. +/// so it's the same on every platform. The details are in the source. #[derive(Debug, thiserror::Error)] pub enum UploadError { #[error(transparent)] @@ -38,6 +42,43 @@ pub enum UploadError { Encode(#[from] WriteError), } +impl UploadError { + pub fn to_failure(&self) -> RemoteCacheFailure { + RemoteCacheFailure { reason: vt_str::format!("{self}"), details: chain(self.source()) } + } +} + +/// A remote cache failure as reported. The reason is the same on every +/// platform. `--last-details` also shows the details, which come from the +/// underlying errors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteCacheFailure { + pub reason: Str, + pub details: Option, +} + +impl RemoteCacheFailure { + /// The reason followed by the details, if any. + pub fn with_details(&self) -> Str { + self.details.as_ref().map_or_else( + || self.reason.clone(), + |details| vt_str::format!("{}: {details}", self.reason), + ) + } +} + +/// The messages of `error` and its sources, joined with `: `. +fn chain(error: Option<&(dyn Error + 'static)>) -> Option { + std::iter::successors(error, |&err| err.source()).fold(None, |chain, err| { + Some( + chain.map_or_else( + || vt_str::format!("{err}"), + |chain| vt_str::format!("{chain}: {err}"), + ), + ) + }) +} + /// Remote cache clients, each created when its endpoint is first used. #[derive(Debug, Default)] pub struct RemoteClients { diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 428b776ae..026235705 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -19,6 +19,7 @@ use crate::session::{ cache::{ CacheMiss, EnvMismatch, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change, + remote::RemoteCacheFailure, }, event::{ CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, @@ -122,7 +123,7 @@ pub enum SpawnOutcome { tool_disabled_cache: bool, /// Why uploading the entry to the remote cache failed, if it did. /// The local cache was still updated. - upload_error: Option, + upload_error: Option, }, /// Process exited with non-zero status. @@ -231,10 +232,10 @@ impl SummaryStats { } SpawnOutcome::Success { .. } => {} } - if let SpawnOutcome::Success { upload_error: Some(reason), .. } = outcome { + if let SpawnOutcome::Success { upload_error: Some(failure), .. } = outcome { stats.upload_failures.push(UploadFailure { task_name: task.format_task_display(), - reason: reason.clone(), + reason: failure.reason.clone(), }); } } @@ -371,9 +372,7 @@ impl TaskResult { CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete) ); let upload_error = match cache_update_status { - CacheUpdateStatus::Updated { upload_error: Some(err) } => { - Some(vt_str::format!("{err}")) - } + CacheUpdateStatus::Updated { upload_error: Some(err) } => Some(err.to_failure()), _ => None, }; @@ -427,7 +426,7 @@ fn spawn_outcome_from_execution( ipc_server_error: Option, tool_disabled_cache: bool, tracking_incomplete: bool, - upload_error: Option, + upload_error: Option, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran @@ -674,7 +673,7 @@ impl TaskResult { } /// Why uploading the entry to the remote cache failed, if it did. - const fn upload_error(&self) -> Option<&Str> { + const fn upload_error(&self) -> Option<&RemoteCacheFailure> { match self { Self::Spawned { outcome: SpawnOutcome::Success { upload_error, .. }, .. } => { upload_error.as_ref() @@ -843,11 +842,11 @@ pub fn format_full_summary(summary: &LastRunSummary) -> Vec { let cache_detail = task.result.format_cache_detail(); let _ = writeln!(buf, " {}", cache_detail.style(task.result.cache_detail_style())); - if let Some(reason) = task.result.upload_error() { + if let Some(failure) = task.result.upload_error() { let _ = writeln!( buf, " {}", - vt_str::format!("⚠ Not uploaded to the remote cache: {reason}") + vt_str::format!("⚠ Not uploaded to the remote cache: {}", failure.with_details()) .style(Style::new().yellow()) ); } @@ -1042,7 +1041,10 @@ mod tests { ipc_server_error: None, tracking_incomplete: false, tool_disabled_cache: false, - upload_error: Some(Str::from(reason)), + upload_error: Some(RemoteCacheFailure { + reason: Str::from(reason), + details: None, + }), }, }, } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml index 395548fcb..aefd17992 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml @@ -77,11 +77,11 @@ steps = [ "cache.example/projects/test", ], ], comment = "The failed upload is a warning. The task succeeds." }, - [ + { argv = [ "vt", "run", "--last-details", - ], + ], comment = "The details include the underlying error." }, { argv = [ "vt", "run", diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md index b8db65430..9bbd3bc41 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/invalid_endpoint.md @@ -13,6 +13,8 @@ vt run: remote-cache#build not uploaded to the remote cache: invalid endpoint. ( ## `vt run --last-details` +The details include the underlying error. + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -26,7 +28,7 @@ Task Details: ──────────────────────────────────────────────── [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ → Cache miss: no previous cache entry found - ⚠ Not uploaded to the remote cache: invalid endpoint + ⚠ Not uploaded to the remote cache: invalid endpoint: relative URL without a base ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/crates/vt_remote_cache/Cargo.toml b/crates/vt_remote_cache/Cargo.toml index b70452488..4351ca4c7 100644 --- a/crates/vt_remote_cache/Cargo.toml +++ b/crates/vt_remote_cache/Cargo.toml @@ -15,12 +15,13 @@ serde = { workspace = true, features = ["derive"] } serde_bytes = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } +url = { workspace = true } vt_path = { workspace = true } +vt_str = { workspace = true } [dev-dependencies] tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } -vt_str = { workspace = true } [lints] workspace = true diff --git a/crates/vt_remote_cache/README.md b/crates/vt_remote_cache/README.md index d17c44c38..e54d4651e 100644 --- a/crates/vt_remote_cache/README.md +++ b/crates/vt_remote_cache/README.md @@ -8,4 +8,4 @@ Client for the [remote cache server API](https://github.com/voidzero-dev/vite-ta reqwest configures TLS. It uses the process's default rustls crypto provider, which the client installs as ring unless one is already installed, and verifies certificates with the operating system's verifier. Connections time out after 10 seconds. Reads time out after 60 seconds, and until the response headers arrive, that limit also covers sending the request. -`Error` names the kind of failure: an invalid endpoint, a client that couldn't be created, a blob file that couldn't be read, a network error (including timeouts), or a status other than 200. Its messages contain no OS-specific details, so they can be shown to users as is. The underlying error is available as the source. +`Error` names the kind of failure: an invalid endpoint, a client that couldn't be created, a blob file that couldn't be read, a network error (including timeouts), or a status other than 200. Its messages contain no OS-specific details, so they can be shown to users as is. The details are in the source: the underlying error, the parse error for an endpoint that isn't a URL, or the message in an error response's body. diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs index e62537759..f7396ed74 100644 --- a/crates/vt_remote_cache/src/lib.rs +++ b/crates/vt_remote_cache/src/lib.rs @@ -4,11 +4,13 @@ use std::time::Duration; use reqwest::{ - StatusCode, Url, + Response, StatusCode, multipart::{Form, Part}, }; use serde::Serialize; +use url::{ParseError, Url}; use vt_path::AbsolutePath; +use vt_str::Str; /// Time allowed to establish a connection, including the TLS handshake. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -18,13 +20,14 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const READ_TIMEOUT: Duration = Duration::from_secs(60); /// A failed remote cache operation. The messages name only the kind of -/// failure, so they are the same on every platform. The underlying error, if -/// any, is the source. +/// failure, so they are the same on every platform. The details, if any, are +/// in the source. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The endpoint isn't an HTTP or HTTPS URL that can have a path. + /// The endpoint isn't an HTTP or HTTPS URL that can have a path. The + /// source is the parse error if it isn't a URL at all. #[error("invalid endpoint")] - InvalidEndpoint, + InvalidEndpoint(#[source] Option), /// The HTTP client couldn't be created, for example because no root /// certificates could be loaded. #[error("failed to create the HTTP client")] @@ -36,11 +39,17 @@ pub enum Error { /// failed or timed out. #[error("network error")] Network(#[source] reqwest::Error), - /// The server responded with a status other than 200. + /// The server responded with a status other than 200. The source is the + /// message in the response body, if any. #[error("HTTP status {}", .0.as_u16())] - Status(StatusCode), + Status(StatusCode, #[source] Option), } +/// The message in the body of an error response. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct ServerMessage(Str); + /// The `metadata` part of a store request. #[derive(Serialize)] struct StoreMetadata<'a> { @@ -109,9 +118,8 @@ impl Client { .send() .await .map_err(Error::Network)?; - let status = response.status(); - if status != StatusCode::OK { - return Err(Error::Status(status)); + if response.status() != StatusCode::OK { + return Err(status_error(response).await); } // The response's blob ID isn't needed. Read the body anyway, so the // connection can be reused. @@ -121,9 +129,9 @@ impl Client { } fn parse_endpoint(endpoint: &str) -> Result { - let url = Url::parse(endpoint).map_err(|_| Error::InvalidEndpoint)?; + let url = Url::parse(endpoint).map_err(|err| Error::InvalidEndpoint(Some(err)))?; if !matches!(url.scheme(), "http" | "https") { - return Err(Error::InvalidEndpoint); + return Err(Error::InvalidEndpoint(None)); } Ok(url) } @@ -131,10 +139,21 @@ fn parse_endpoint(endpoint: &str) -> Result { /// Append `route` to the endpoint's path, keeping its namespace path. fn route_url(endpoint: &Url, route: &str) -> Result { let mut url = endpoint.clone(); - url.path_segments_mut().map_err(|()| Error::InvalidEndpoint)?.pop_if_empty().push(route); + url.path_segments_mut().map_err(|()| Error::InvalidEndpoint(None))?.pop_if_empty().push(route); Ok(url) } +/// The error for a response with a status other than 200, with the message in +/// its body. +async fn status_error(response: Response) -> Error { + let status = response.status(); + let message = response.text().await.ok().and_then(|text| { + let text = text.trim(); + (!text.is_empty()).then(|| ServerMessage(Str::from(text))) + }); + Error::Status(status, message) +} + fn encode_metadata(metadata: &StoreMetadata<'_>) -> Vec { let mut bytes = Vec::new(); ciborium::into_writer(metadata, &mut bytes) @@ -185,7 +204,7 @@ mod tests { fn rejects_endpoints_that_are_not_http_urls() { for endpoint in ["cache.example/projects/test", "ftp://cache.example", "mailto:a@b.example"] { - assert!(matches!(Client::new(endpoint), Err(Error::InvalidEndpoint)), "{endpoint}"); + assert!(matches!(Client::new(endpoint), Err(Error::InvalidEndpoint(_))), "{endpoint}"); } } @@ -255,14 +274,15 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let server = std::thread::spawn(move || { - serve_once(&listener, "HTTP/1.1 500 Internal Server Error", b"") + serve_once(&listener, "HTTP/1.1 500 Internal Server Error", b"storage failed\n") }); let client = Client::new(&vt_str::format!("http://127.0.0.1:{port}/projects/test")).unwrap(); - let result = client.store(b"k", b"s", b"v", Some(&blob)).await; - assert!(matches!(result, Err(Error::Status(StatusCode::INTERNAL_SERVER_ERROR)))); - assert_eq!(result.unwrap_err().to_string(), "HTTP status 500"); + let err = client.store(b"k", b"s", b"v", Some(&blob)).await.unwrap_err(); + assert!(matches!(err, Error::Status(StatusCode::INTERNAL_SERVER_ERROR, _))); + assert_eq!(err.to_string(), "HTTP status 500"); + assert_eq!(std::error::Error::source(&err).unwrap().to_string(), "storage failed"); let request = server.join().unwrap(); assert!(request.starts_with(b"POST /projects/test/store HTTP/1.1\r\n")); From e1ea3a9f4d2ad0fe43d6251af572c88b184efcf1 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 20:10:35 +0800 Subject: [PATCH 7/8] refactor(cache): map remote cache upload errors to messages in the reporter The cache module keeps the error as is. The summary reporter decides, for each kind of error, its reason and the details that `--last-details` shows. Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/remote.rs | 54 ++++----------- crates/vt/src/session/reporter/summary.rs | 80 ++++++++++++++++++++--- 2 files changed, 81 insertions(+), 53 deletions(-) diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index ba4a3d8d4..922604253 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -12,13 +12,9 @@ //! target OS and architecture. Platforms share an endpoint's namespace, but //! their keys differ. -use std::{ - error::Error, - sync::{Arc, Mutex, PoisonError}, -}; +use std::sync::{Arc, Mutex, PoisonError}; use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; use vt_path::AbsolutePath; use vt_plan::cache_metadata::ExecutionCacheKey; use vt_remote_cache::Client; @@ -32,53 +28,25 @@ use super::{ CACHE_SCHEMA_VERSION, CacheEntryKey, CacheEntryValue, TaskCacheConfig, serialize_cache, }; -/// Why an entry wasn't uploaded. The message names only the kind of failure, -/// so it's the same on every platform. The details are in the source. -#[derive(Debug, thiserror::Error)] +/// Why an entry wasn't uploaded. The reporter turns it into messages. +#[derive(Debug)] pub enum UploadError { - #[error(transparent)] - Remote(#[from] vt_remote_cache::Error), - #[error("failed to encode the cache entry")] - Encode(#[from] WriteError), + Remote(vt_remote_cache::Error), + Encode(WriteError), } -impl UploadError { - pub fn to_failure(&self) -> RemoteCacheFailure { - RemoteCacheFailure { reason: vt_str::format!("{self}"), details: chain(self.source()) } +impl From for UploadError { + fn from(err: vt_remote_cache::Error) -> Self { + Self::Remote(err) } } -/// A remote cache failure as reported. The reason is the same on every -/// platform. `--last-details` also shows the details, which come from the -/// underlying errors. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteCacheFailure { - pub reason: Str, - pub details: Option, -} - -impl RemoteCacheFailure { - /// The reason followed by the details, if any. - pub fn with_details(&self) -> Str { - self.details.as_ref().map_or_else( - || self.reason.clone(), - |details| vt_str::format!("{}: {details}", self.reason), - ) +impl From for UploadError { + fn from(err: WriteError) -> Self { + Self::Encode(err) } } -/// The messages of `error` and its sources, joined with `: `. -fn chain(error: Option<&(dyn Error + 'static)>) -> Option { - std::iter::successors(error, |&err| err.source()).fold(None, |chain, err| { - Some( - chain.map_or_else( - || vt_str::format!("{err}"), - |chain| vt_str::format!("{chain}: {err}"), - ), - ) - }) -} - /// Remote cache clients, each created when its endpoint is first used. #[derive(Debug, Default)] pub struct RemoteClients { diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 026235705..e138eb326 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -19,7 +19,7 @@ use crate::session::{ cache::{ CacheMiss, EnvMismatch, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change, - remote::RemoteCacheFailure, + remote::UploadError, }, event::{ CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, @@ -123,7 +123,7 @@ pub enum SpawnOutcome { tool_disabled_cache: bool, /// Why uploading the entry to the remote cache failed, if it did. /// The local cache was still updated. - upload_error: Option, + upload_error: Option, }, /// Process exited with non-zero status. @@ -174,6 +174,17 @@ pub enum SavedCacheErrorKind { Update, } +/// A failed remote cache operation, serializable for persistence. +/// +/// `reason` names only the kind of failure, so it's the same on every +/// platform. `details` has the underlying error, which only `--last-details` +/// shows. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedRemoteCacheError { + reason: Str, + details: Option, +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Computed stats (derived from tasks, not persisted) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -232,10 +243,10 @@ impl SummaryStats { } SpawnOutcome::Success { .. } => {} } - if let SpawnOutcome::Success { upload_error: Some(failure), .. } = outcome { + if let SpawnOutcome::Success { upload_error: Some(error), .. } = outcome { stats.upload_failures.push(UploadFailure { task_name: task.format_task_display(), - reason: failure.reason.clone(), + reason: error.reason.clone(), }); } } @@ -306,6 +317,53 @@ impl SavedExecutionError { } } +impl SavedRemoteCacheError { + /// Convert a live [`UploadError`] into a serializable error. + fn from_upload_error(error: &UploadError) -> Self { + match error { + UploadError::Remote(error) => Self::from_client_error(error), + UploadError::Encode(error) => Self { + reason: Str::from("failed to encode the cache entry"), + details: Some(vt_str::format!("{error}")), + }, + } + } + + /// The client's message is the reason. The details come from the + /// underlying error, if any. + fn from_client_error(error: &vt_remote_cache::Error) -> Self { + use vt_remote_cache::Error; + + let details = match error { + Error::InvalidEndpoint(parse_error) => { + parse_error.as_ref().map(|parse_error| vt_str::format!("{parse_error}")) + } + // reqwest's own message only says which request failed. The cause, + // such as a refused connection, is in its sources. + Error::HttpClient(error) | Error::Network(error) => Some(error_chain(error)), + Error::ReadBlob(error) => Some(vt_str::format!("{error}")), + Error::Status(_, message) => { + message.as_ref().map(|message| vt_str::format!("{message}")) + } + }; + Self { reason: vt_str::format!("{error}"), details } + } + + /// Format the reason and details for display. + fn display_message(&self) -> Str { + self.details.as_ref().map_or_else( + || self.reason.clone(), + |details| vt_str::format!("{}: {details}", self.reason), + ) + } +} + +/// The messages of `error` and its sources, joined with `: `. +fn error_chain(error: &(dyn std::error::Error + 'static)) -> Str { + std::iter::successors(error.source(), |&source| source.source()) + .fold(vt_str::format!("{error}"), |chain, source| vt_str::format!("{chain}: {source}")) +} + impl SavedCacheMissReason { fn from_cache_miss(cache_miss: &CacheMiss) -> Self { match cache_miss { @@ -372,7 +430,9 @@ impl TaskResult { CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete) ); let upload_error = match cache_update_status { - CacheUpdateStatus::Updated { upload_error: Some(err) } => Some(err.to_failure()), + CacheUpdateStatus::Updated { upload_error: Some(err) } => { + Some(SavedRemoteCacheError::from_upload_error(err)) + } _ => None, }; @@ -426,7 +486,7 @@ fn spawn_outcome_from_execution( ipc_server_error: Option, tool_disabled_cache: bool, tracking_incomplete: bool, - upload_error: Option, + upload_error: Option, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran @@ -673,7 +733,7 @@ impl TaskResult { } /// Why uploading the entry to the remote cache failed, if it did. - const fn upload_error(&self) -> Option<&RemoteCacheFailure> { + const fn upload_error(&self) -> Option<&SavedRemoteCacheError> { match self { Self::Spawned { outcome: SpawnOutcome::Success { upload_error, .. }, .. } => { upload_error.as_ref() @@ -842,11 +902,11 @@ pub fn format_full_summary(summary: &LastRunSummary) -> Vec { let cache_detail = task.result.format_cache_detail(); let _ = writeln!(buf, " {}", cache_detail.style(task.result.cache_detail_style())); - if let Some(failure) = task.result.upload_error() { + if let Some(error) = task.result.upload_error() { let _ = writeln!( buf, " {}", - vt_str::format!("⚠ Not uploaded to the remote cache: {}", failure.with_details()) + vt_str::format!("⚠ Not uploaded to the remote cache: {}", error.display_message()) .style(Style::new().yellow()) ); } @@ -1041,7 +1101,7 @@ mod tests { ipc_server_error: None, tracking_incomplete: false, tool_disabled_cache: false, - upload_error: Some(RemoteCacheFailure { + upload_error: Some(SavedRemoteCacheError { reason: Str::from(reason), details: None, }), From abdec51134a62c48e790dbf200b7b0f6747adcde Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 20:21:45 +0800 Subject: [PATCH 8/8] test(cache): show `--last-details` for the unreachable remote cache endpoint The E2E redaction replaces OS error messages, which differ by platform, with ``. Co-authored-by: Claude Opus 5.5 --- .../fixtures/remote_cache/snapshots.toml | 5 +++++ .../snapshots/unreachable_endpoint.md | 21 +++++++++++++++++++ crates/vt_bin/tests/e2e_snapshots/redact.rs | 5 +++++ 3 files changed, 31 insertions(+) diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml index aefd17992..02e279e93 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml @@ -115,4 +115,9 @@ steps = [ "http://127.0.0.1:0/projects/test", ], ], comment = "Nothing can listen on port 0. The failed upload is a warning. The task succeeds." }, + { argv = [ + "vt", + "run", + "--last-details", + ], comment = "The details include the underlying error." }, ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md index fa7ea2306..a83bed2c1 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/unreachable_endpoint.md @@ -10,3 +10,24 @@ $ vtt write-file dist/output.txt built --- vt run: remote-cache#build not uploaded to the remote cache: network error. (Run `vt run --last-details` for full details) ``` + +## `vt run --last-details` + +The details include the underlying error. + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ + → Cache miss: no previous cache entry found + ⚠ Not uploaded to the remote cache: network error: error sending request for url (http://127.0.0.1:0/projects/test/store): client error (Connect): tcp connect error: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/redact.rs b/crates/vt_bin/tests/e2e_snapshots/redact.rs index 8d7469015..f0545987d 100644 --- a/crates/vt_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vt_bin/tests/e2e_snapshots/redact.rs @@ -76,6 +76,11 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { regex::Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").unwrap(); output = uuid_regex.replace_all(&output, "").into_owned(); + // Redact OS error messages like "Connection refused (os error 111)", which + // differ by platform, to "" + let os_error_regex = regex::Regex::new(r": [^:\n]* \(os error -?\d+\)").unwrap(); + output = os_error_regex.replace_all(&output, ": ").into_owned(); + // Redact durations like "0ns", "123ms" or "1.23s" to "" let duration_regex = regex::Regex::new(r"\d+(\.\d+)?(ns|ms|s)").unwrap(); output = duration_regex.replace_all(&output, "").into_owned();