From 9d6e24a87d457c83482f743721078049481ef143 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 23:23:18 +0800 Subject: [PATCH 1/4] fix(cache): harden remote cache requests - Redirects aren't followed, so a redirected store fails with its status instead of counting a login page's 200 as success. - The system proxy settings are used on macOS and Windows, as with reqwest's default features. - Network errors leave out the request URL, which may contain credentials that would otherwise be saved in the run summary. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 66 ++++++++++++++++++- .../snapshots/read_unreachable_endpoint.md | 2 +- .../snapshots/unreachable_endpoint.md | 4 +- crates/vt_remote_cache/Cargo.toml | 7 +- crates/vt_remote_cache/README.md | 6 +- crates/vt_remote_cache/src/lib.rs | 54 +++++++++++++-- 6 files changed, 123 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c4838b5b..67a03f1e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,6 +708,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1906,9 +1916,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3768,7 +3780,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3851,7 +3863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4333,6 +4345,27 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81c645a4de0d803ced6ef0388a2646aa1ef8467173b5d59a2c33c88de4ab76e7" +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.45" @@ -5687,6 +5720,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_unreachable_endpoint.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_unreachable_endpoint.md index f61035964..5ca66a91e 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_unreachable_endpoint.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/read_unreachable_endpoint.md @@ -26,7 +26,7 @@ Task Details: [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ → Cache miss: remote cache fetch failed ↳ network error - ↳ error sending request for url (http://127.0.0.1:0/projects/test/fetch) + ↳ error sending request ↳ client error (Connect) ↳ tcp connect 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 daa2d7e5d..fdd8b380c 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 @@ -29,12 +29,12 @@ Task Details: [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ → Cache miss: remote cache fetch failed ↳ network error - ↳ error sending request for url (http://127.0.0.1:0/projects/test/fetch) + ↳ error sending request ↳ client error (Connect) ↳ tcp connect error ↳ ⚠ Not uploaded to the remote cache: network error - ↳ error sending request for url (http://127.0.0.1:0/projects/test/store) + ↳ error sending request ↳ client error (Connect) ↳ tcp connect error ↳ diff --git a/crates/vt_remote_cache/Cargo.toml b/crates/vt_remote_cache/Cargo.toml index 0ab0131a8..750b76f06 100644 --- a/crates/vt_remote_cache/Cargo.toml +++ b/crates/vt_remote_cache/Cargo.toml @@ -10,7 +10,12 @@ rust-version.workspace = true [dependencies] bytes = { workspace = true } ciborium = { workspace = true } -reqwest = { workspace = true, features = ["multipart", "rustls-no-provider", "stream"] } +reqwest = { workspace = true, features = [ + "multipart", + "rustls-no-provider", + "stream", + "system-proxy", +] } rustls = { workspace = true, features = ["ring", "std"] } serde = { workspace = true, features = ["derive"] } serde_bytes = { workspace = true } diff --git a/crates/vt_remote_cache/README.md b/crates/vt_remote_cache/README.md index 08650ece6..a9bb658f0 100644 --- a/crates/vt_remote_cache/README.md +++ b/crates/vt_remote_cache/README.md @@ -10,8 +10,8 @@ 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. The response body isn't decoded. -Only HTTP 200 counts as success for every operation. +Only HTTP 200 counts as success for every operation. Redirects aren't followed, so a redirect fails like any other status. -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. +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. Requests go through the proxy set in `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY`, except for hosts in `NO_PROXY`. Without those variables, the system proxy settings are used on macOS and Windows. 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 and responses that end early), a status other than 200, or a malformed fetch response. 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. +`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 and responses that end early), a status other than 200, or a malformed fetch response. 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. Network errors leave out the request URL, since the endpoint may contain credentials, such as a token in its query. diff --git a/crates/vt_remote_cache/src/lib.rs b/crates/vt_remote_cache/src/lib.rs index d47a37d98..51912d38f 100644 --- a/crates/vt_remote_cache/src/lib.rs +++ b/crates/vt_remote_cache/src/lib.rs @@ -38,7 +38,9 @@ pub enum Error { #[error("failed to read the blob")] ReadBlob(#[source] std::io::Error), /// No complete response arrived, for example because the connection - /// failed, timed out, or closed before the whole body arrived. + /// failed, timed out, or closed before the whole body arrived. The source + /// leaves out the request URL, because the endpoint may contain + /// credentials, such as a token in its query. #[error("network error")] Network(#[source] reqwest::Error), /// The server responded with a status other than 200. The source is the @@ -69,7 +71,7 @@ impl Download { /// Returns an error if the rest of the blob can't be read, for example /// because the connection closes before it all arrives. pub async fn chunk(&mut self) -> Result, Error> { - self.response.chunk().await.map_err(Error::Network) + self.response.chunk().await.map_err(network_error) } } @@ -146,9 +148,12 @@ impl Client { // Installing fails if one is already installed; vite-plus installs // ring too. let _ = rustls::crypto::ring::default_provider().install_default(); + // A redirect fails like any other status. Following one could turn a + // store into a GET of a login page that responds with 200. let http = reqwest::Client::builder() .connect_timeout(CONNECT_TIMEOUT) .read_timeout(READ_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(Error::HttpClient)?; Ok(Self { http, fetch_url, store_url, blob_url }) @@ -170,8 +175,8 @@ impl Client { .body(body) .send() .await - .map_err(Error::Network)?; - let body = check_status(response).await?.bytes().await.map_err(Error::Network)?; + .map_err(network_error)?; + let body = check_status(response).await?.bytes().await.map_err(network_error)?; decode_fetched(&body) } @@ -186,7 +191,7 @@ impl Client { pub async fn download(&self, blob_id: &str) -> Result { let mut url = self.blob_url.clone(); url.path_segments_mut().map_err(|()| Error::InvalidEndpoint(None))?.push(blob_id); - let response = self.http.get(url).send().await.map_err(Error::Network)?; + let response = self.http.get(url).send().await.map_err(network_error)?; Ok(Download { response: check_status(response).await? }) } @@ -216,10 +221,10 @@ impl Client { .multipart(form) .send() .await - .map_err(Error::Network)?; + .map_err(network_error)?; // The response's blob ID isn't needed. Read the body anyway, so the // connection can be reused. - check_status(response).await?.bytes().await.map_err(Error::Network)?; + check_status(response).await?.bytes().await.map_err(network_error)?; Ok(()) } } @@ -238,6 +243,10 @@ async fn check_status(response: Response) -> Result { Err(Error::Status(status, message)) } +fn network_error(err: reqwest::Error) -> Error { + Error::Network(err.without_url()) +} + fn decode_fetched(body: &[u8]) -> Result { ciborium::from_reader(body).map_err(Error::MalformedResponse) } @@ -565,4 +574,35 @@ mod tests { assert!(contains(&request, &metadata_part_bytes())); assert!(!contains(&request, b"name=\"blob\"")); } + + #[tokio::test] + async fn store_does_not_follow_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = client_for(&listener); + let server = std::thread::spawn(move || { + serve_raw_once( + &listener, + b"HTTP/1.1 302 Found\r\nlocation: /login\r\ncontent-length: 0\r\n\r\n", + ) + }); + + let error = client.store(b"k", b"s", b"v", None).await.unwrap_err(); + assert!(matches!(error, Error::Status(StatusCode::FOUND, None)), "{error:?}"); + server.join().unwrap(); + } + + #[tokio::test] + async fn network_errors_leave_out_the_url() { + let client = + Client::new("http://user:password@127.0.0.1:0/projects/test?token=secret").unwrap(); + + let error = client.fetch(b"k", b"s").await.unwrap_err(); + assert!(matches!(error, Error::Network(_)), "{error:?}"); + let messages = + std::iter::successors(Some(&error as &dyn std::error::Error), |err| err.source()) + .map(|err| vt_str::format!("{err}")); + for text in messages.chain([vt_str::format!("{error:?}")]) { + assert!(!text.contains("password") && !text.contains("secret"), "{text}"); + } + } } From af531d6425121efcf3ee807263dfe0aa606a1853 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 23:26:28 +0800 Subject: [PATCH 2/4] fix(cache): treat a remote entry whose blob doesn't match its value as a miss An exact entry whose value has an output archive but no blob ID was recorded and replayed without its outputs, on this hit and every later local hit. A value with an archive and an entry with a blob now have to agree; otherwise it's a read failure. Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/remote.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index b8d545a34..411794573 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -58,6 +58,10 @@ pub enum ReadError { CorruptValue(#[source] wincode::error::ReadError), #[error("remote cache key is corrupt")] CorruptKey(#[source] Option), + /// The value has an output archive but the entry has no blob, or the + /// other way around. + #[error("remote cache entry's blob doesn't match its value")] + MismatchedBlob, #[error("downloaded archive is corrupt")] CorruptArchive(#[source] io::Error), #[error("failed to write the downloaded archive")] @@ -85,7 +89,8 @@ pub(super) struct Restore { /// Turn the result of a fetch into an entry to restore or a miss. `validate` /// checks an exact entry against the current execution. A fallback's miss /// reason compares its key with `cache_key`. A failed fetch, an entry that -/// doesn't decode, or a validation error is a read failure. +/// doesn't decode or whose blob doesn't match its value, or a validation error +/// is a read failure. #[expect( clippy::result_large_err, reason = "`CacheMiss` is intentionally large, and a lookup returns it once" @@ -97,8 +102,11 @@ pub(super) fn resolve( ) -> Result { let (value, blob_id) = match fetched.map_err(ReadError::into_miss)? { Fetched::Exact { value, blob_id } => { - let value = deserialize_cache(&value) + let value: CacheEntryValue = deserialize_cache(&value) .map_err(|err| ReadError::CorruptValue(err).into_miss())?; + if value.output_archive.is_some() != blob_id.is_some() { + return Err(ReadError::MismatchedBlob.into_miss()); + } (value, blob_id) } Fetched::Fallback { key } => { @@ -377,7 +385,7 @@ mod tests { } fn not_validated(_: &CacheEntryValue) -> anyhow::Result> { - panic!("only exact entries that decode are validated") + panic!("only exact entries that decode and match their blob are validated") } fn read_failure(miss: CacheMiss) -> Str { @@ -479,6 +487,17 @@ mod tests { } } + #[test] + fn blob_that_does_not_match_the_value_is_a_read_failure() { + let key = cache_key(ResolvedGlobConfig::default_auto()); + let without_archive = CacheEntryValue { output_archive: None, ..cache_value() }; + for (value, blob_id) in [(cache_value(), None), (without_archive, Some(Str::from("1")))] { + let fetched = Ok(Fetched::Exact { value: serialize_cache(&value).unwrap(), blob_id }); + let miss = resolve(fetched, &key, not_validated).unwrap_err(); + assert_eq!(read_failure(miss), "remote cache entry's blob doesn't match its value"); + } + } + #[test] fn fallback_key_that_does_not_decode_is_a_corrupt_entry() { let key = cache_key(ResolvedGlobConfig::default_auto()); From edc3a1beadcb8803578d7df75dda517e64f7605b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 23:26:29 +0800 Subject: [PATCH 3/4] fix(cache): stop a remote download as soon as the archive check fails The download waited for the server's next chunk before noticing the failed check, up to the read timeout on a stalled connection. Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/remote.rs | 50 ++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index 411794573..a048c2caa 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -230,13 +230,18 @@ async fn download_checked( } /// Send the blob's chunks through `sender` until the blob ends or the receiver -/// is dropped. +/// is dropped. The check drops the receiver when it fails, which stops the +/// download without waiting for the next chunk. async fn send_chunks( mut download: Download, sender: mpsc::Sender, ) -> Result<(), vt_remote_cache::Error> { - while let Some(chunk) = download.chunk().await? { - // The check drops the receiver when it fails. + loop { + let chunk = tokio::select! { + chunk = download.chunk() => chunk?, + () = sender.closed() => break, + }; + let Some(chunk) = chunk else { break }; if sender.send(chunk).await.is_err() { break; } @@ -306,10 +311,10 @@ fn decode_key(bytes: &[u8]) -> Result { #[cfg(test)] mod tests { - use std::{collections::BTreeMap, time::Duration}; + use std::{collections::BTreeMap, io::Read as _, net::TcpListener, time::Duration}; use vt_graph::config::ResolvedGlobConfig; - use vt_path::RelativePathBuf; + use vt_path::{AbsolutePathBuf, RelativePathBuf}; use vt_plan::cache_metadata::{EnvValueHash, SpawnFingerprint}; use super::*; @@ -516,4 +521,39 @@ mod tests { assert_eq!(read_failure(miss), "remote cache key is corrupt"); } } + + #[tokio::test] + async fn failed_archive_check_stops_the_download() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint: Arc = + Arc::from(vt_str::format!("http://{}/projects/test", listener.local_addr().unwrap())); + let (done_sender, done_receiver) = std::sync::mpsc::channel::<()>(); + // The response announces more than it sends and stays open until the + // test is done, so only the failed check can end the download. + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut buf = [0; 1024]; + let n = stream.read(&mut buf).unwrap(); + assert_ne!(n, 0, "connection closed before the request ended"); + request.extend_from_slice(&buf[..n]); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 1000\r\n\r\nnot an archive") + .unwrap(); + let _ = done_receiver.recv(); + }); + let dir = tempfile::tempdir().unwrap(); + let cache_dir = AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + + let error = RemoteClients::default() + .download_archive(&endpoint, "1", &cache_dir) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::CorruptArchive(_)), "{error:?}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + drop(done_sender); + server.join().unwrap(); + } } From 431c004d9766c785238d217f7570227f19f75366 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 27 Sep 2026 23:27:13 +0800 Subject: [PATCH 4/4] docs(changelog): mention remote cache proxy settings Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d64401e1..2faa74ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)). - **Changed** The detailed summary from `vp run --verbose` and `vp run --last-details` now shows each underlying cause of an error on its own line ([#761](https://github.com/voidzero-dev/vite-task/pull/761)). -- **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. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also 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), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757)). +- **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. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also 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 }`. Requests use the proxy environment variables or, on macOS and Windows, the system proxy settings ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757), [#764](https://github.com/voidzero-dev/vite-task/pull/764)). - **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)).