From 8af1c9dbfd4dc61eb058ed0a8440c4747f73e92a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 2 Oct 2026 10:02:47 +0800 Subject: [PATCH 1/3] fix(cache): treat a cache entry with an overflowing duration as corrupt Decoding a cache entry built its duration with `Duration::new` without checking the fields, so seconds near `u64::MAX` plus nanoseconds that carry over panicked and aborted the run. Use wincode's own `Duration` schema, which has the same encoding and rejects the overflow, so a remote entry like this is a corrupt value and a cache miss. Fixes #780 Co-Authored-By: Claude Opus 5.5 --- crates/vt/src/session/cache/mod.rs | 37 +------------------------ crates/vt/src/session/cache/remote.rs | 39 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 1f2d725ef..fe3fcfdd4 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -26,9 +26,8 @@ use vt_plan::{ use vt_str::Str; use wincode::{ SchemaRead, SchemaReadOwned, SchemaWrite, - config::{ConfigCore, Configuration}, + config::Configuration, error::{ReadResult, WriteResult}, - io::{Reader, Writer}, }; use self::remote::{ReadError, RemoteClients, Restore, UploadError}; @@ -90,39 +89,6 @@ impl CacheEntryKey { } } -/// wincode schema adapter for `Duration`. -struct DurationSchema; - -// SAFETY: Writes exactly `size_of::() + size_of::()` bytes matching size_of. -unsafe impl SchemaWrite for DurationSchema { - type Src = Duration; - - fn size_of(_src: &Self::Src) -> WriteResult { - Ok(size_of::() + size_of::()) - } - - fn write(mut writer: impl Writer, src: &Self::Src) -> WriteResult<()> { - >::write(writer.by_ref(), &src.as_secs())?; - >::write(writer.by_ref(), &src.subsec_nanos())?; - Ok(()) - } -} - -// SAFETY: Reads u64 + u32, matching the write format; dst is initialized on Ok. -unsafe impl<'de, C: ConfigCore> SchemaRead<'de, C> for DurationSchema { - type Dst = Duration; - - fn read( - mut reader: impl Reader<'de>, - dst: &mut std::mem::MaybeUninit, - ) -> ReadResult<()> { - let secs = >::get(&mut reader)?; - let nanos = >::get(&mut reader)?; - dst.write(Duration::new(secs, nanos)); - Ok(()) - } -} - /// Cached execution result for a task. /// /// Contains the post-run fingerprint (from fspy), captured outputs, @@ -131,7 +97,6 @@ unsafe impl<'de, C: ConfigCore> SchemaRead<'de, C> for DurationSchema { pub struct CacheEntryValue { pub post_run_fingerprint: PostRunFingerprint, pub std_outputs: Arc<[StdOutput]>, - #[wincode(with = "DurationSchema")] pub duration: Duration, /// Hashes of explicit input files computed from positive globs. /// Files matching negative globs are already filtered out. diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index 18008f28e..f72473f8f 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -519,6 +519,45 @@ mod tests { } } + /// Regression test for #780: decoding a duration whose nanoseconds carry + /// past `u64::MAX` seconds used to panic. + #[test] + fn value_with_an_overflowing_duration_is_a_corrupt_entry() { + /// `CacheEntryValue` with the duration's seconds and nanoseconds as + /// separate fields. Update it if `CacheEntryValue` changes. + #[derive(SchemaWrite)] + struct CacheEntryValueLayout { + post_run_fingerprint: PostRunFingerprint, + std_outputs: Arc<[StdOutput]>, + duration_secs: u64, + duration_nanos: u32, + globbed_inputs: BTreeMap, + output_archive: Option, + } + let encode = |duration_nanos| { + let CacheEntryValue { post_run_fingerprint, std_outputs, globbed_inputs, .. } = + cache_value(); + serialize_cache(&CacheEntryValueLayout { + post_run_fingerprint, + std_outputs, + duration_secs: u64::MAX, + duration_nanos, + globbed_inputs, + output_archive: None, + }) + .unwrap() + }; + let key = cache_key(ResolvedGlobConfig::default_auto()); + + let fetched = Ok(Some(Fetched::Exact { value: encode(999_999_999), blob_id: None })); + let restore = resolve(fetched, &key, validate_against(BTreeMap::new())).unwrap(); + assert_eq!(restore.value.duration, Duration::MAX); + + let fetched = Ok(Some(Fetched::Exact { value: encode(1_000_000_000), blob_id: None })); + let miss = resolve(fetched, &key, not_validated).unwrap_err(); + assert_eq!(read_failure(miss), "remote cache value is corrupt"); + } + #[test] fn blob_that_does_not_match_the_value_is_a_read_failure() { let key = cache_key(ResolvedGlobConfig::default_auto()); From 8e0a3c7bb2fb5be8c1d535bb5fe9cc1eb082c70d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 2 Oct 2026 10:03:21 +0800 Subject: [PATCH 2/3] docs(changelog): link #786 under remote caching 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 51ed141ac..5aa6da994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)). - **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. Ctrl-C, or a failing task, stops remote cache requests right away, and a task still being looked up doesn't start. 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), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772)). +- **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. Ctrl-C, or a failing task, stops remote cache requests right away, and a task still being looked up doesn't start. 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), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772), [#786](https://github.com/voidzero-dev/vite-task/pull/786)). - **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)). From 2ec08158146789f312efc4140f6079bdf2240a7b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 2 Oct 2026 10:08:17 +0800 Subject: [PATCH 3/3] test(cache): drop the overflowing duration regression test Co-Authored-By: Claude Opus 5.5 --- crates/vt/src/session/cache/remote.rs | 39 --------------------------- 1 file changed, 39 deletions(-) diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index f72473f8f..18008f28e 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -519,45 +519,6 @@ mod tests { } } - /// Regression test for #780: decoding a duration whose nanoseconds carry - /// past `u64::MAX` seconds used to panic. - #[test] - fn value_with_an_overflowing_duration_is_a_corrupt_entry() { - /// `CacheEntryValue` with the duration's seconds and nanoseconds as - /// separate fields. Update it if `CacheEntryValue` changes. - #[derive(SchemaWrite)] - struct CacheEntryValueLayout { - post_run_fingerprint: PostRunFingerprint, - std_outputs: Arc<[StdOutput]>, - duration_secs: u64, - duration_nanos: u32, - globbed_inputs: BTreeMap, - output_archive: Option, - } - let encode = |duration_nanos| { - let CacheEntryValue { post_run_fingerprint, std_outputs, globbed_inputs, .. } = - cache_value(); - serialize_cache(&CacheEntryValueLayout { - post_run_fingerprint, - std_outputs, - duration_secs: u64::MAX, - duration_nanos, - globbed_inputs, - output_archive: None, - }) - .unwrap() - }; - let key = cache_key(ResolvedGlobConfig::default_auto()); - - let fetched = Ok(Some(Fetched::Exact { value: encode(999_999_999), blob_id: None })); - let restore = resolve(fetched, &key, validate_against(BTreeMap::new())).unwrap(); - assert_eq!(restore.value.duration, Duration::MAX); - - let fetched = Ok(Some(Fetched::Exact { value: encode(1_000_000_000), blob_id: None })); - let miss = resolve(fetched, &key, not_validated).unwrap_err(); - assert_eq!(read_failure(miss), "remote cache value is corrupt"); - } - #[test] fn blob_that_does_not_match_the_value_is_a_read_failure() { let key = cache_key(ResolvedGlobConfig::default_auto());