diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf7fc0ea..674434045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Added** Runner-aware tools can now opt the current task run out of caching through the new IPC channel; Vite dev server integration uses this automatically ([#441](https://github.com/voidzero-dev/vite-task/pull/441)) - **Fixed** Prefix environment assignments like `PATH=... command` now affect executable lookup during task planning, so tools provided only by the prefixed `PATH` can be resolved correctly ([#440](https://github.com/voidzero-dev/vite-task/pull/440)) - **Changed** Cache misses caused by a tracked env var now name the env var inline, for example `cache miss: env 'NODE_ENV' changed`, instead of the generic `envs changed` message ([#438](https://github.com/voidzero-dev/vite-task/pull/438)) - **Fixed** The task cache is now stored in a per-schema-version subdirectory (e.g. `node_modules/.vite/task-cache/v13/`), so switching between branches that pin different Vite+ versions no longer fails with `Unrecognized database version`. Each version keeps its own cache directory; a cache from a different version is ignored rather than aborting the run ([#433](https://github.com/voidzero-dev/vite-task/pull/433)) diff --git a/Cargo.lock b/Cargo.lock index 3da36c591..ad37f767c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,7 +467,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -610,6 +610,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "copy_dir" version = "0.1.3" @@ -923,7 +932,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version 0.4.1", @@ -1832,6 +1841,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.12" @@ -2045,6 +2064,64 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "napi" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" +dependencies = [ + "bitflags 2.10.0", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "tracing", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42" +dependencies = [ + "convert_case 0.11.0", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver 1.0.27", + "syn 2.0.117", +] + +[[package]] +name = "napi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +dependencies = [ + "libloading 0.9.0", +] + [[package]] name = "native_str" version = "0.0.0" @@ -2105,6 +2182,12 @@ dependencies = [ "memoffset 0.9.1", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -4163,6 +4246,8 @@ dependencies = [ "derive_more", "fspy", "futures-util", + "materialized_artifact", + "materialized_artifact_build", "nix 0.31.2", "once_cell", "owo-colors", @@ -4185,8 +4270,11 @@ dependencies = [ "vite_path", "vite_select", "vite_str", + "vite_task_client_napi", "vite_task_graph", + "vite_task_ipc_shared", "vite_task_plan", + "vite_task_server", "vite_workspace", "wax", "winapi", @@ -4228,6 +4316,29 @@ dependencies = [ "which", ] +[[package]] +name = "vite_task_client" +version = "0.0.0" +dependencies = [ + "native_str", + "rustc-hash", + "vite_path", + "vite_task_ipc_shared", + "winapi", + "wincode", +] + +[[package]] +name = "vite_task_client_napi" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "vite_str", + "vite_task_client", +] + [[package]] name = "vite_task_graph" version = "0.1.0" @@ -4251,6 +4362,15 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_task_ipc_shared" +version = "0.0.0" +dependencies = [ + "native_str", + "rustc-hash", + "wincode", +] + [[package]] name = "vite_task_plan" version = "0.1.0" @@ -4289,6 +4409,26 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_task_server" +version = "0.0.0" +dependencies = [ + "futures", + "native_str", + "rustc-hash", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "vite_glob", + "vite_path", + "vite_task_client", + "vite_task_ipc_shared", + "wincode", +] + [[package]] name = "vite_tui" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index a73393501..0b9187367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,9 @@ libc = "0.2.185" libtest-mimic = "0.8.2" memmap2 = "0.9.7" monostate = "1.0.2" +napi = "3" +napi-build = "2" +napi-derive = "3" native_str = { path = "crates/native_str" } nix = { version = "0.31.2", features = ["dir", "signal"] } ntapi = "0.4.1" @@ -150,8 +153,12 @@ vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } vite_task = { path = "crates/vite_task" } vite_task_bin = { path = "crates/vite_task_bin" } +vite_task_client = { path = "crates/vite_task_client" } +vite_task_client_napi = { path = "crates/vite_task_client_napi", artifact = "cdylib", target = "target" } vite_task_graph = { path = "crates/vite_task_graph" } +vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" } vite_task_plan = { path = "crates/vite_task_plan" } +vite_task_server = { path = "crates/vite_task_server" } vite_workspace = { path = "crates/vite_workspace" } vt100 = "0.16.2" wax = "0.7.0" @@ -169,6 +176,7 @@ ignored = [ # These are artifact dependencies. They are not directly `use`d in Rust code. "fspy_preload_unix", "fspy_preload_windows", + "vite_task_client_napi", ] [profile.dev] diff --git a/crates/native_str/src/lib.rs b/crates/native_str/src/lib.rs index 64d4a930a..647f01035 100644 --- a/crates/native_str/src/lib.rs +++ b/crates/native_str/src/lib.rs @@ -37,7 +37,7 @@ use wincode::{ /// **Not portable across platforms.** The binary representation is platform-specific. /// Deserializing a `NativeStr` serialized on a different platform leads to unspecified /// behavior (garbage data), but is not unsafe. Designed for same-platform IPC only. -#[derive(TransparentWrapper, PartialEq, Eq)] +#[derive(TransparentWrapper, PartialEq, Eq, Hash)] #[repr(transparent)] pub struct NativeStr { // On unix, this is the raw bytes of the OsStr. diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 90f510572..2bc0ad7e3 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -43,16 +43,27 @@ tokio = { workspace = true, features = [ tokio-util = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } +materialized_artifact = { workspace = true } uuid = { workspace = true, features = ["v4"] } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } +vite_task_ipc_shared = { workspace = true } vite_task_plan = { workspace = true } +vite_task_server = { workspace = true } vite_workspace = { workspace = true } wax = { workspace = true } zstd = { workspace = true } +# Artifact build-deps must be unconditional: cargo's resolver panics when +# `artifact = "cdylib"` deps live under a `[target.cfg.build-dependencies]` +# block on cross-compile. +[build-dependencies] +anyhow = { workspace = true } +materialized_artifact_build = { workspace = true } +vite_task_client_napi = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vite_task/build.rs b/crates/vite_task/build.rs index 6acc864ed..4f1fbe191 100644 --- a/crates/vite_task/build.rs +++ b/crates/vite_task/build.rs @@ -1,3 +1,13 @@ +#![expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "build.rs interfaces with std::path and cargo's env-var API" +)] + +use std::{env, path::Path}; + +use anyhow::Context; + // Why `cfg(fspy)` instead of matching on `target_os` directly at each use site: // "fspy is available" is a single semantic predicate, but the underlying reason // (the `fspy` crate builds on windows/macos/linux) is a three-OS list that @@ -7,12 +17,18 @@ // over OSes. The OS allowlist lives in two spots that must stay in sync: this // file (for the rustc cfg) and the target-scoped dep block in Cargo.toml // (which Cargo resolves before build.rs runs, so it can't reuse this cfg). -fn main() { +fn main() -> anyhow::Result<()> { println!("cargo::rustc-check-cfg=cfg(fspy)"); println!("cargo::rerun-if-changed=build.rs"); - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); if matches!(target_os.as_str(), "windows" | "macos" | "linux") { println!("cargo::rustc-cfg=fspy"); } + + let env_name = "CARGO_CDYLIB_FILE_VITE_TASK_CLIENT_NAPI"; + println!("cargo:rerun-if-env-changed={env_name}"); + let dylib_path = env::var_os(env_name).with_context(|| format!("{env_name} not set"))?; + materialized_artifact_build::register("vite_task_client_napi", Path::new(&dylib_path)); + Ok(()) } diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index 8a8f03867..3c330796c 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -1,5 +1,6 @@ mod cli; mod collections; +mod napi_client; pub mod session; // Public exports for vite_task_bin diff --git a/crates/vite_task/src/napi_client.rs b/crates/vite_task/src/napi_client.rs new file mode 100644 index 000000000..dce8328de --- /dev/null +++ b/crates/vite_task/src/napi_client.rs @@ -0,0 +1,32 @@ +//! The `vite_task_client_napi` cdylib is embedded into the `vp` binary and +//! materialized to disk on first use so tools can `require()` it at runtime. + +use std::{env, fs, sync::LazyLock}; + +use materialized_artifact::artifact; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// Path to the materialized `vite_task_client_napi` `.node` addon. +/// +/// The file is written to a process-wide temp directory on first call and +/// reused on every subsequent call (content-addressed filename; no re-writes). +/// +/// # Panics +/// +/// Panics if the materialization fails on first call — this mirrors fspy's +/// `SPY_IMPL` and the same reasoning applies: if we can't write into the +/// system temp dir, the runner can't run tasks anyway. +#[must_use] +pub fn napi_client_path() -> &'static AbsolutePath { + static PATH: LazyLock = LazyLock::new(|| { + let dir = env::temp_dir().join("vite_task_client_napi"); + let _ = fs::create_dir(&dir); + let path = artifact!("vite_task_client_napi") + .materialize() + .suffix(".node") + .at(&dir) + .expect("materialize vite_task_client_napi"); + AbsolutePathBuf::new(path).expect("system temp dir yields an absolute path") + }); + PATH.as_absolute_path() +} diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index a0ca481c3..798eb6551 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -1,6 +1,7 @@ use std::{process::ExitStatus, time::Duration}; use vite_path::RelativePathBuf; +use vite_task_server::Error as IpcServerError; use super::cache::CacheMiss; @@ -43,6 +44,12 @@ pub enum ExecutionError { /// Creating the post-run fingerprint failed after successful execution. #[error("Failed to create post-run fingerprint")] PostRunFingerprint(#[source] anyhow::Error), + + /// The runner-aware IPC server failed to bind for this task. Reported + /// instead of silently degrading so that `{ auto: true }` inputs stay + /// observable end-to-end. + #[error("Failed to start runner IPC server")] + IpcServerBind(#[source] std::io::Error), } #[derive(Debug, Clone)] @@ -76,6 +83,14 @@ pub enum CacheNotUpdatedReason { /// (its `input` config includes auto-inference). Task ran but cannot /// be cached without tracked path accesses. FspyUnsupported, + /// The runner's IPC server failed during execution, so the collected + /// reports may be incomplete. Caching such a run would risk stale + /// inputs/outputs on the next hit. Carries the underlying error for + /// user-facing reporting. + IpcServerError(IpcServerError), + /// A runner-aware tool explicitly requested that this run not be cached + /// (e.g. vite dev-server, a watch task). + ToolRequested, } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/cache_update.rs b/crates/vite_task/src/session/execute/cache_update.rs index ccfaa45a5..45b679e52 100644 --- a/crates/vite_task/src/session/execute/cache_update.rs +++ b/crates/vite_task/src/session/execute/cache_update.rs @@ -6,6 +6,7 @@ use std::{sync::Arc, time::Duration}; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; use vite_task_plan::cache_metadata::CacheMetadata; +use vite_task_server::Reports; use super::{ CacheState, @@ -37,16 +38,30 @@ struct TrackingOutcome { /// `finish()` call; this function never reports by itself. The guard clauses /// run in priority order — each names the reason the run is *not* cached, and /// only a run that passes them all is stored. +#[expect( + clippy::too_many_arguments, + reason = "the run's full context is genuinely needed to decide and store the cache entry" +)] pub(super) async fn update_cache( cache: &ExecutionCache, workspace_root: &Arc, cache_dir: &AbsolutePath, state: CacheState<'_>, outcome: &ChildOutcome, + reports: Option<&Reports>, duration: Duration, cancelled: bool, ) -> (CacheUpdateStatus, Option) { - let CacheState { metadata, globbed_inputs, std_outputs, fspy_negatives } = state; + let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state; + let fspy_negatives = tracking.as_ref().map(|t| t.input_negative_globs.as_slice()); + + if let Some(reports) = reports + && reports.cache_disabled + { + // A runner-aware tool short-circuited caching via `disableCache()` + // (e.g. a dev server with no deterministic output). + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), None); + } if cancelled { // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy. @@ -58,7 +73,7 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } - let fspy_outcome = observe_fspy(outcome, fspy_negatives.as_deref(), workspace_root); + let fspy_outcome = observe_fspy(outcome, fspy_negatives, workspace_root); if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { // fspy-inferred read-write overlap: the task wrote to a file it also diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index dd3a5575a..7b550877c 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -10,11 +10,19 @@ pub mod tracked_accesses; #[cfg(windows)] mod win_job; -use std::{collections::BTreeMap, sync::Arc, time::Instant}; +use std::{ + collections::BTreeMap, + ffi::{OsStr, OsString}, + sync::Arc, + time::Instant, +}; +use futures_util::future::LocalBoxFuture; use tokio_util::sync::CancellationToken; use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_task_ipc_shared::NODE_CLIENT_PATH_ENV_NAME; use vite_task_plan::{SpawnExecution, cache_metadata::CacheMetadata}; +use vite_task_server::{Recorder, Reports, ServerHandle, StopAccepting, serve}; use self::{ glob::compute_globbed_inputs, @@ -80,10 +88,47 @@ struct CacheState<'a> { /// Captured stdout/stderr for cache replay. Written in place during drain; /// always present (possibly empty) once we reach the cache-update phase. std_outputs: Vec, - /// `Some` iff fspy is enabled (`includes_auto`). Holds the resolved - /// negative globs used to filter tracked accesses. `None` means fspy - /// tracking is off for this task. - fspy_negatives: Option>>, + /// `Some` iff auto-input tracking is on (`input.includes_auto` + successful + /// IPC bind). Bundles fspy's input negative globs with the per-task IPC + /// server that runner-aware tools talk to. Parts are borrowed in place + /// during the wait/join; the struct is never moved out. + tracking: Option, +} + +/// The IPC server's driver future: resolves with the recorded reports after +/// [`StopAccepting::signal`] fires and all in-flight clients drain. +type IpcDriver = LocalBoxFuture<'static, Result>; + +/// Per-task tracking: fspy input-negative globs + IPC server handle. +/// Lifetime-tied to a single `execute_spawn` call. +struct Tracking { + input_negative_globs: Vec>, + ipc_envs: Vec<(&'static OsStr, OsString)>, + ipc_server_fut: IpcDriver, + stop_accepting: StopAccepting, +} + +/// The IPC server's handles for the run phase. The two halves are +/// inseparable — whenever the driver is polled, `stop_accepting` must be +/// signalled after the child exits or the driver never resolves — so they +/// travel as one value and a driver-without-signal state is unrepresentable. +struct IpcHandles<'m> { + /// Signalled after the child exits so the server stops accepting and + /// drains. + stop_accepting: &'m StopAccepting, + /// The server's driver, polled alongside the child. + driver: &'m mut IpcDriver, +} + +/// Mode-scoped borrows for the run phase, extracted in one pass so the pipe +/// sinks and the IPC handles can be borrowed simultaneously (disjoint fields +/// of the same mode). +struct RunHandles<'m> { + /// Pipe writers + capture slot. `None` only in the inherited-uncached + /// case, where there are no pipes to drain. + sinks: Option>, + /// The IPC server's handles. `None` iff tracking is off. + ipc: Option>, } impl<'a> ExecutionMode<'a> { @@ -113,9 +158,9 @@ impl<'a> ExecutionMode<'a> { }); }; - // Resolve input negative globs for fspy path filtering (already - // workspace-root-relative). - let fspy_negatives = if metadata.input_config.includes_auto { + let tracking = if metadata.input_config.includes_auto { + // Resolve input negative globs for fspy path filtering (already + // workspace-root-relative). let negatives = metadata .input_config .negative_globs @@ -123,43 +168,81 @@ impl<'a> ExecutionMode<'a> { .map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned())) .collect::>>() .map_err(ExecutionError::PostRunFingerprint)?; - Some(negatives) + // fspy + IPC are bundled. If binding the IPC server fails we abort + // the execution — tools that rely on IPC would otherwise silently + // diverge from the cache. + let (envs, ServerHandle { driver, stop_accepting }) = + serve(Recorder::new()).map_err(ExecutionError::IpcServerBind)?; + Some(Tracking { + input_negative_globs: negatives, + ipc_envs: envs.collect(), + ipc_server_fut: driver, + stop_accepting, + }) } else { None }; Ok(Self::Cached { pipe_writers: stdio_config.writers, - state: CacheState { metadata, globbed_inputs, std_outputs: Vec::new(), fspy_negatives }, + state: CacheState { metadata, globbed_inputs, std_outputs: Vec::new(), tracking }, }) } + /// The extra envs to inject into the child: IPC connection info + the + /// napi addon path runner-aware tools `require()`. Empty when tracking + /// is off. + fn injected_envs(&self) -> Vec<(&OsStr, &OsStr)> { + match self { + Self::Cached { state: CacheState { tracking: Some(t), .. }, .. } => { + let mut envs: Vec<(&OsStr, &OsStr)> = + t.ipc_envs.iter().map(|(k, v)| (*k, v.as_os_str())).collect(); + envs.push(( + OsStr::new(NODE_CLIENT_PATH_ENV_NAME), + crate::napi_client::napi_client_path().as_path().as_os_str(), + )); + envs + } + _ => Vec::new(), + } + } + /// The arguments `spawn()` derives from the mode: stdio handling and /// whether fspy tracking is on. const fn spawn_config(&self) -> (SpawnStdio, bool) { match self { - Self::Cached { state, .. } => (SpawnStdio::Piped, state.fspy_negatives.is_some()), + Self::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.is_some()), Self::Uncached { pipe_writers: Some(_) } => (SpawnStdio::Piped, false), Self::Uncached { pipe_writers: None } => (SpawnStdio::Inherited, false), } } - /// Borrow the pipe writers (and, when caching, the in-place capture slot) - /// for the drain. `None` only in the inherited-uncached case, where there - /// are no pipes to drain. - fn pipe_sinks(&mut self) -> Option> { + /// Extract all mode-scoped borrows for the run phase in one pass: the + /// pipe sinks plus the IPC server's handles (disjoint field borrows + /// inside the same match arm). + fn run_handles(&mut self) -> RunHandles<'_> { match self { - Self::Cached { pipe_writers, state } => Some(PipeSinks { - stdout_writer: &mut pipe_writers.stdout_writer, - stderr_writer: &mut pipe_writers.stderr_writer, - capture: Some(&mut state.std_outputs), - }), - Self::Uncached { pipe_writers: Some(pipe_writers) } => Some(PipeSinks { - stdout_writer: &mut pipe_writers.stdout_writer, - stderr_writer: &mut pipe_writers.stderr_writer, - capture: None, - }), - Self::Uncached { pipe_writers: None } => None, + Self::Cached { pipe_writers, state } => { + let sinks = Some(PipeSinks { + stdout_writer: &mut pipe_writers.stdout_writer, + stderr_writer: &mut pipe_writers.stderr_writer, + capture: Some(&mut state.std_outputs), + }); + let ipc = state.tracking.as_mut().map(|t| IpcHandles { + stop_accepting: &t.stop_accepting, + driver: &mut t.ipc_server_fut, + }); + RunHandles { sinks, ipc } + } + Self::Uncached { pipe_writers: Some(pipe_writers) } => RunHandles { + sinks: Some(PipeSinks { + stdout_writer: &mut pipe_writers.stdout_writer, + stderr_writer: &mut pipe_writers.stderr_writer, + capture: None, + }), + ipc: None, + }, + Self::Uncached { pipe_writers: None } => RunHandles { sinks: None, ipc: None }, } } } @@ -327,20 +410,61 @@ async fn run( // 5. Spawn. Returns pipes (Piped) or `None` (Inherited) plus a // cancellation-aware wait future. let (spawn_stdio, fspy_enabled) = mode.spawn_config(); - let child = - spawn(&spawn_execution.spawn_command, fspy_enabled, spawn_stdio, fast_fail_token.clone()) - .await - .map_err(|err| Report::failed(ExecutionError::Spawn(err)))?; - - // 6. Drain the pipes and wait for exit. Box::pin keeps the child-and-pipe - // stack off the enclosing future: pipe_stdio alone makes the combined - // future large enough to trip clippy::large_futures in every caller - // otherwise. - let outcome = Box::pin(run_child(child, mode.pipe_sinks(), fast_fail_token.clone())) - .await - .map_err(|err| Report::failed(ExecutionError::Spawn(err)))?; + let child = spawn( + &spawn_execution.spawn_command, + fspy_enabled, + spawn_stdio, + fast_fail_token.clone(), + mode.injected_envs(), + ) + .await + .map_err(|err| Report::failed(ExecutionError::Spawn(err)))?; + + // 6. Drain the pipes and wait for exit, concurrently with the IPC driver. + // The driver must be polled during pipe drain — otherwise a tool doing + // a blocking `getEnv` can deadlock: child stalls on IPC, stdout stays + // open, pipe_stdio waits for EOF, driver never runs. `stop_accepting` + // fires after child.wait so the driver drains any in-flight clients. + // Box::pin keeps the child-and-pipe stack off the enclosing future: + // pipe_stdio alone makes the combined future large enough to trip + // clippy::large_futures in every caller otherwise. + let RunHandles { sinks, ipc } = mode.run_handles(); + let (wait_result, ipc_server_result) = if let Some(IpcHandles { stop_accepting, driver }) = ipc + { + let child_work = + Box::pin(run_child(child, sinks, Some(stop_accepting), fast_fail_token.clone())); + let (wait_result, join_result) = tokio::join!(child_work, driver); + if let Err(e) = &join_result { + tracing::warn!(?e, "IPC server failed; cache will not be updated"); + } + (wait_result, Some(join_result.map(Recorder::into_reports))) + } else { + let child_work = Box::pin(run_child(child, sinks, None, fast_fail_token.clone())); + (child_work.await, None) + }; + let outcome = wait_result.map_err(|err| Report::failed(ExecutionError::Spawn(err)))?; let duration = start.elapsed(); + // Extract reports, or short-circuit when the IPC server failed. An Err + // here means reports may be incomplete: caching this run would risk + // stale inputs/outputs, so skip all cache-related computation entirely. + let reports: Option = match ipc_server_result { + Some(Ok(reports)) => { + tracing::debug!(?reports, "runner-aware tools reported"); + Some(reports) + } + None => None, + Some(Err(err)) => { + return Err(Report::Spawned { + exit_status: outcome.exit_status, + cache_update: CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::IpcServerError( + err, + )), + error: None, + }); + } + }; + // 7. Decide the cache update (only when we were in `Cached` mode). Cache // update errors are reported but do not affect the exit status we // return — the process ran, so we return its actual status. @@ -353,6 +477,7 @@ async fn run( cache_dir, state, &outcome, + reports.as_ref(), duration, cancelled, ) @@ -454,10 +579,12 @@ fn replay_cache_hit( /// Phase 6: drain the child's pipes (if piped) and wait for exit, with a /// single error sink — a pipe failure cancels (so the wait kills the child /// instead of orphaning it) and surfaces through the same returned result as -/// a wait failure. +/// a wait failure. After the child exits (on every path), `stop_accepting` +/// is signalled so the IPC server stops accepting and starts draining. async fn run_child( mut child: ChildHandle, sinks: Option>, + stop_accepting: Option<&StopAccepting>, fast_fail_token: CancellationToken, ) -> anyhow::Result { let pipe_result: anyhow::Result<()> = if let Some(sinks) = sinks { @@ -473,14 +600,19 @@ async fn run_child( Ok(()) }; - match pipe_result { + let wait_result = match pipe_result { Ok(()) => child.wait.await.map_err(anyhow::Error::from), Err(err) => { // Pipe failed — cancel so `child.wait` kills the child instead of - // orphaning it. + // orphaning it. Still signal the server below so it can drain. fast_fail_token.cancel(); let _ = child.wait.await; Err(err) } + }; + + if let Some(stop_accepting) = stop_accepting { + stop_accepting.signal(); } + wait_result } diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 2ed64fc83..711903373 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -5,7 +5,7 @@ //! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s (only //! compiled when `cfg(fspy)` is on). -use std::{io, process::Stdio}; +use std::{ffi::OsStr, io, process::Stdio}; #[cfg(fspy)] use fspy::PathAccessIterable; @@ -48,21 +48,31 @@ pub struct ChildOutcome { /// Spawn a command with the requested fspy and stdio configuration. /// +/// `extra_envs` are applied **after** `cmd.all_envs`, so runtime-injected +/// entries (e.g. the runner's IPC name + napi addon path) override any +/// same-named key from the plan. +/// /// Cancellation is unified: whether fspy is enabled or not, the returned `wait` /// future observes `cancellation_token` and kills the child before resolving. /// /// On builds without `cfg(fspy)`, the `fspy` argument is ignored and the tokio /// path is always taken. #[tracing::instrument(level = "debug", skip_all)] -pub async fn spawn( +pub async fn spawn( cmd: &SpawnCommand, fspy: bool, stdio: SpawnStdio, cancellation_token: CancellationToken, -) -> anyhow::Result { + extra_envs: E, +) -> anyhow::Result +where + E: IntoIterator, + K: AsRef, + V: AsRef, +{ #[cfg(fspy)] if fspy { - return spawn_fspy(cmd, stdio, cancellation_token).await; + return spawn_fspy(cmd, stdio, cancellation_token, extra_envs).await; } #[cfg(not(fspy))] let _ = fspy; @@ -71,20 +81,28 @@ pub async fn spawn( tokio_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); tokio_cmd.env_clear(); tokio_cmd.envs(cmd.all_envs.iter()); + tokio_cmd.envs(extra_envs); tokio_cmd.current_dir(&*cmd.cwd); apply_stdio(&mut tokio_cmd, stdio); spawn_tokio(tokio_cmd, cancellation_token) } #[cfg(fspy)] -async fn spawn_fspy( +async fn spawn_fspy( cmd: &SpawnCommand, stdio: SpawnStdio, cancellation_token: CancellationToken, -) -> anyhow::Result { + extra_envs: E, +) -> anyhow::Result +where + E: IntoIterator, + K: AsRef, + V: AsRef, +{ let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); fspy_cmd.envs(cmd.all_envs.iter()); + fspy_cmd.envs(extra_envs); fspy_cmd.current_dir(&*cmd.cwd); match stdio { diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index f1a01168f..1a362b2fc 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -107,6 +107,12 @@ pub enum SpawnOutcome { /// Task ran successfully but cache was not updated. #[serde(default)] fspy_unsupported: bool, + /// Rendered message of the IPC server error that caused the cache to + /// be skipped, if any. + ipc_server_error: Option, + /// Set when a runner-aware tool called `disableCache()`, skipping + /// cache update. + tool_disabled_cache: bool, }, /// Process exited with non-zero status. @@ -141,6 +147,7 @@ pub enum SavedExecutionError { Cache { kind: SavedCacheErrorKind, message: Str }, Spawn { message: Str }, PostRunFingerprint { message: Str }, + IpcServerBind { message: Str }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -228,6 +235,9 @@ impl SavedExecutionError { ExecutionError::PostRunFingerprint(source) => { Self::PostRunFingerprint { message: vite_str::format!("{source:#}") } } + ExecutionError::IpcServerBind(source) => { + Self::IpcServerBind { message: vite_str::format!("{source:#}") } + } } } @@ -247,6 +257,7 @@ impl SavedExecutionError { Self::PostRunFingerprint { message } => { vite_str::format!("Failed to create post-run fingerprint: {message}") } + Self::IpcServerBind { .. } => Str::from("Failed to start runner IPC server"), } } } @@ -293,6 +304,16 @@ impl TaskResult { cache_update_status, CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported) ); + let ipc_server_error = match cache_update_status { + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::IpcServerError(err)) => { + Some(vite_str::format!("{err}")) + } + _ => None, + }; + let tool_disabled_cache = matches!( + cache_update_status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested) + ); match cache_status { CacheStatus::Hit { replayed_duration } => { @@ -306,6 +327,8 @@ impl TaskResult { saved_error, input_modified_path, fspy_unsupported, + ipc_server_error, + tool_disabled_cache, ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { @@ -317,6 +340,8 @@ impl TaskResult { saved_error, input_modified_path, fspy_unsupported, + ipc_server_error, + tool_disabled_cache, ), }, } @@ -329,6 +354,8 @@ fn spawn_outcome_from_execution( saved_error: Option<&SavedExecutionError>, input_modified_path: Option, fspy_unsupported: bool, + ipc_server_error: Option, + tool_disabled_cache: bool, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran @@ -338,6 +365,8 @@ fn spawn_outcome_from_execution( infra_error: saved_error.cloned(), input_modified_path, fspy_unsupported, + ipc_server_error, + tool_disabled_cache, }, // Process exited with non-zero code (Some(status), _) => { @@ -356,6 +385,8 @@ fn spawn_outcome_from_execution( infra_error: None, input_modified_path: None, fspy_unsupported: false, + ipc_server_error: None, + tool_disabled_cache: false, }, } } @@ -467,7 +498,26 @@ impl TaskResult { /// - "→ Cache miss: no previous cache entry found" /// - "→ Cache disabled in task configuration" fn format_cache_detail(&self) -> Str { - // Check for input modification first — it overrides the cache miss reason + // Check for IPC server error first — short-circuits before any cache + // computation in `execute_spawn`, so it takes priority. + if let Self::Spawned { + outcome: SpawnOutcome::Success { ipc_server_error: Some(err), .. }, + .. + } = self + { + return vite_str::format!("→ Not cached: IPC server error: {err}"); + } + + // Tool-reported cache disable — the tool said it shouldn't be cached. + if let Self::Spawned { + outcome: SpawnOutcome::Success { tool_disabled_cache: true, .. }, + .. + } = self + { + return Str::from("→ Not cached: tool requested disableCache"); + } + + // Check for input modification next — it overrides the cache miss reason if let Self::Spawned { outcome: SpawnOutcome::Success { input_modified_path: Some(path), .. }, .. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json new file mode 100644 index 000000000..be1e0be88 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json @@ -0,0 +1,5 @@ +{ + "name": "ipc-client-test", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs new file mode 100644 index 000000000..f868cef50 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs @@ -0,0 +1,8 @@ +import { disableCache } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// Produce an output, then ask the runner not to cache this execution — the +// next `vt run` should re-execute the task. +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); +disableCache(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml new file mode 100644 index 000000000..19e517c83 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -0,0 +1,19 @@ +[[e2e]] +name = "disable_cache_forces_reexecution" +comment = """ +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "first run — tool calls disableCache" }, + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "cache miss (NotFound) because nothing was cached" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md new file mode 100644 index 000000000..150158547 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md @@ -0,0 +1,20 @@ +# disable_cache_forces_reexecution + +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. + +## `vt run disable-cache` + +first run — tool calls disableCache + +``` +$ node scripts/disable_cache.mjs +``` + +## `vt run disable-cache` + +cache miss (NotFound) because nothing was cached + +``` +$ node scripts/disable_cache.mjs +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json new file mode 100644 index 000000000..a91991012 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "disable-cache": { + "command": "node scripts/disable_cache.mjs", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/dev.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/dev.mjs new file mode 100644 index 000000000..3cf79b82a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/dev.mjs @@ -0,0 +1,14 @@ +// Programmatic Vite dev server bring-up: middleware mode skips the HTTP +// listen entirely (Windows runners refuse the 127.0.0.1 bind with +// `listen UNKNOWN`), but `_createServer` still calls `disableCache()` +// via `@voidzero-dev/vite-task-client` on its first line — so even +// though this process exits 0 the runner is told not to store the run +// and the next invocation must miss. +import { createServer } from 'vite'; + +const server = await createServer({ + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true }, +}); +await server.close(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/package.json new file mode 100644 index 000000000..23ec17d56 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/package.json @@ -0,0 +1,5 @@ +{ + "name": "vite-dev-disable-cache-fixture", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots.toml new file mode 100644 index 000000000..efd50410e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots.toml @@ -0,0 +1,20 @@ +[[e2e]] +name = "vite_dev_disables_cache" +comment = """ +`vt run --cache dev` brings up a Vite dev server programmatically on an ephemeral port and closes it immediately. Vite's `_createServer` calls `disableCache()` via `@voidzero-dev/vite-task-client`, so this run is never stored — the next invocation re-executes (cache miss / NotFound). +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "dev", + ], comment = "first run — Vite dev start calls disableCache" }, + { argv = [ + "vt", + "run", + "--cache", + "dev", + ], comment = "cache miss (NotFound) because the first run was not stored" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots/vite_dev_disables_cache.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots/vite_dev_disables_cache.md new file mode 100644 index 000000000..9f5e07491 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots/vite_dev_disables_cache.md @@ -0,0 +1,19 @@ +# vite_dev_disables_cache + +`vt run --cache dev` brings up a Vite dev server programmatically on an ephemeral port and closes it immediately. Vite's `_createServer` calls `disableCache()` via `@voidzero-dev/vite-task-client`, so this run is never stored — the next invocation re-executes (cache miss / NotFound). + +## `vt run --cache dev` + +first run — Vite dev start calls disableCache + +``` +$ node dev.mjs +``` + +## `vt run --cache dev` + +cache miss (NotFound) because the first run was not stored + +``` +$ node dev.mjs +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/vite-task.json new file mode 100644 index 000000000..1b8982a59 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": { + "command": "node dev.mjs", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 2de549308..f0f6782c6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -283,6 +283,56 @@ fn render_formatted_screen(bytes: &[u8]) -> String { out } +/// Copy the `@voidzero-dev/vite-task-client` JS wrapper into the fixture's +/// staging `node_modules` so Node scripts can resolve it by name. Idempotent — +/// silently skipped if the source package is not found. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn populate_vite_task_client_package(stage_path: &AbsolutePath) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let src = repo_root.join("packages/vite-task-client"); + if !src.is_dir() { + return; + } + let dst = stage_path.as_path().join("node_modules/@voidzero-dev/vite-task-client"); + std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); + CopyOptions::new().copy_tree(&src, &dst).unwrap(); +} + +/// Symlink installed Node packages from the repo's `packages/tools/node_modules` +/// into the fixture's staging `node_modules` so fixtures can resolve them by +/// name without a per-fixture pnpm install. Only packages whose staging-side +/// symlink targets exist are created; missing targets are silently skipped. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn link_tools_packages(stage_path: &AbsolutePath, names: &[&str]) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let stage_node_modules = stage_path.as_path().join("node_modules"); + std::fs::create_dir_all(&stage_node_modules).unwrap(); + + for name in names { + let src = repo_root.join("packages/tools/node_modules").join(name); + // Follow the symlink so the absolute target (pnpm's .pnpm store) is + // what we pin into the staging tree. Relative symlinks into pnpm + // internals would break outside the repo. + let Ok(canonical) = std::fs::canonicalize(&src) else { + continue; + }; + let link = stage_node_modules.join(name); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink(&canonical, &link).unwrap(); + #[cfg(windows)] + { + if canonical.is_dir() { + std::os::windows::fs::symlink_dir(&canonical, &link).unwrap(); + } else { + std::os::windows::fs::symlink_file(&canonical, &link).unwrap(); + } + } + } +} + /// Append a fenced markdown block containing `body`. The opening and closing /// fences sit on their own lines, and trailing whitespace inside `body` is /// trimmed so the close fence isn't preceded by blank lines. @@ -319,6 +369,19 @@ fn run_case( let e2e_stage_path = tmpdir.join(vite_str::format!("{fixture_name}_case_{case_index}")); CopyOptions::new().copy_tree(fixture_path, e2e_stage_path.as_path()).unwrap(); + // Make `@voidzero-dev/vite-task-client` importable from any fixture's Node + // scripts by copying the wrapper package into the staging dir's + // `node_modules`. This mirrors the user-facing flow (`import { ... } from + // "@voidzero-dev/vite-task-client"`) without requiring pnpm install. + populate_vite_task_client_package(&e2e_stage_path); + + // Fixtures that exercise real Node toolchains (e.g. `vite build`) link + // those packages from the repo's `packages/tools/node_modules` so the + // tool and its transitive deps (resolved via pnpm) stay reachable. + if matches!(fixture_name, "vite_build_cache" | "vite_dev_disable_cache") { + link_tools_packages(&e2e_stage_path, &["vite"]); + } + let (workspace_root, _cwd) = find_workspace_root(&e2e_stage_path).unwrap(); assert_eq!( &e2e_stage_path, &*workspace_root.path, @@ -331,8 +394,19 @@ fn run_case( let bin = AbsolutePathBuf::new(std::path::PathBuf::from(bin_path)).unwrap(); Arc::::from(bin.parent().unwrap().as_path().as_os_str()) }); + + // Also expose tool bins installed under packages/tools/node_modules/.bin + // (e.g. `vite`) so ignored e2e fixtures can exercise real toolchains. + #[expect(clippy::disallowed_types, reason = "PathBuf needed for workspace path arithmetic")] + let tools_bin_dir: Option> = { + let manifest_dir = std::path::PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let tools_bin = repo_root.join("packages/tools/node_modules/.bin"); + tools_bin.is_dir().then(|| Arc::::from(tools_bin.into_os_string())) + }; + let e2e_env_path = join_paths( - bin_dirs.iter().cloned().chain( + bin_dirs.iter().cloned().chain(tools_bin_dir.iter().cloned()).chain( // the existing PATH split_paths(&env::var_os("PATH").unwrap()) .map(|path| Arc::::from(path.into_os_string())), diff --git a/crates/vite_task_client/Cargo.toml b/crates/vite_task_client/Cargo.toml new file mode 100644 index 000000000..b926bc01f --- /dev/null +++ b/crates/vite_task_client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vite_task_client" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +native_str = { workspace = true } +rustc-hash = { workspace = true } +vite_path = { workspace = true } +vite_task_ipc_shared = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[target.'cfg(windows)'.dependencies] +winapi = { workspace = true, features = ["namedpipeapi"] } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_client/README.md b/crates/vite_task_client/README.md new file mode 100644 index 000000000..f1ab5ee44 --- /dev/null +++ b/crates/vite_task_client/README.md @@ -0,0 +1,3 @@ +# vite_task_client + +IPC client that connects from tool processes to the task runner to report inputs/outputs, request env values, and disable caching. diff --git a/crates/vite_task_client/src/lib.rs b/crates/vite_task_client/src/lib.rs new file mode 100644 index 000000000..eaba98cd0 --- /dev/null +++ b/crates/vite_task_client/src/lib.rs @@ -0,0 +1,264 @@ +use std::{ + cell::RefCell, + ffi::OsStr, + io::{self, Read, Write}, + sync::Arc, +}; + +use native_str::NativeStr; +use rustc_hash::FxHashMap; +use vite_path::{self, AbsolutePath}; +use vite_task_ipc_shared::{GetEnvResponse, GetEnvsResponse, IPC_ENV_NAME, Request}; + +#[cfg(unix)] +type Stream = std::os::unix::net::UnixStream; +#[cfg(windows)] +type Stream = std::fs::File; + +pub struct Client { + stream: RefCell, + scratch: RefCell>, +} + +impl Client { + /// Scans `envs` for the runner's IPC connection info and connects if + /// present. Typical callers pass `std::env::vars_os()`. + /// + /// Returns `Ok(None)` if the IPC env is absent (running outside the runner). + /// `Err(..)` if the env is set but connecting fails. + /// + /// # Errors + /// + /// Returns an error if the env var is set but the server cannot be reached. + pub fn from_envs( + envs: impl Iterator, impl AsRef)>, + ) -> io::Result> { + for (name, value) in envs { + if name.as_ref() == IPC_ENV_NAME { + let stream = connect(value.as_ref())?; + return Ok(Some(Self::from_stream(stream))); + } + } + Ok(None) + } + + const fn from_stream(stream: Stream) -> Self { + Self { stream: RefCell::new(stream), scratch: RefCell::new(Vec::new()) } + } + + /// `path` can be a file or a directory; for a directory, all files inside + /// it are ignored. Relative paths are resolved against the current working + /// directory before being sent to the runner. + /// + /// Fire-and-forget: the call returns once the request is flushed to the + /// kernel pipe buffer; the runner processes it during its drain phase + /// after this process exits. See the `Request` type in the IPC protocol + /// crate for why this is safe. + /// + /// # Errors + /// + /// Returns an error if the request fails to send, or (for a relative + /// `path`) if the current working directory cannot be read. + pub fn ignore_input(&self, path: &OsStr) -> io::Result<()> { + let ns = resolve_path(path)?; + self.send(&Request::IgnoreInput(&ns)) + } + + /// `path` can be a file or a directory; for a directory, all files inside + /// it are ignored. Relative paths are resolved against the current working + /// directory before being sent to the runner. + /// + /// Fire-and-forget — see [`Self::ignore_input`]. + /// + /// # Errors + /// + /// Returns an error if the request fails to send, or (for a relative + /// `path`) if the current working directory cannot be read. + pub fn ignore_output(&self, path: &OsStr) -> io::Result<()> { + let ns = resolve_path(path)?; + self.send(&Request::IgnoreOutput(&ns)) + } + + /// Fire-and-forget — see [`Self::ignore_input`]. + /// + /// # Errors + /// + /// Returns an error if the request fails to send. + pub fn disable_cache(&self) -> io::Result<()> { + self.send(&Request::DisableCache) + } + + /// Requests an env value from the runner. Returns `None` if the runner reports + /// the env is not available. + /// + /// # Errors + /// + /// Returns an error if the request or response fails. + pub fn get_env(&self, name: &OsStr, tracked: bool) -> io::Result>> { + let name = Box::::from(name); + + self.send(&Request::GetEnv { name: &name, tracked })?; + self.recv_with(|bytes| { + let response: GetEnvResponse<'_> = wincode::deserialize_exact(bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + Ok(response + .env_value + .map(|env_value| Arc::::from(env_value.to_cow_os_str().as_ref()))) + }) + } + + /// Requests every env whose name matches `pattern` from the runner. The + /// returned map is keyed by env name with its value. + /// + /// Unlike [`Self::get_env`], this always round-trips to the server — the + /// client has no way to know in advance which names the pattern matches. + /// Env names that aren't valid UTF-8 are silently dropped at the server. + /// + /// # Errors + /// + /// Returns an error if the request or response fails, or if the server + /// rejected the pattern as an invalid glob. + pub fn get_envs( + &self, + pattern: &str, + tracked: bool, + ) -> io::Result, Arc>> { + self.send(&Request::GetEnvs { pattern, tracked })?; + self.recv_with(|bytes| { + let response: GetEnvsResponse<'_> = wincode::deserialize_exact(bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + Ok(response + .entries + .iter() + .map(|(name, value)| { + ( + Arc::::from(name.to_cow_os_str().as_ref()), + Arc::::from(value.to_cow_os_str().as_ref()), + ) + }) + .collect()) + }) + } + + fn send(&self, request: &Request<'_>) -> io::Result<()> { + let bytes = wincode::serialize(request) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let len = u32::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "request too large"))?; + let mut stream = self.stream.borrow_mut(); + stream.write_all(&len.to_le_bytes())?; + stream.write_all(&bytes)?; + stream.flush()?; + Ok(()) + } + + fn recv_with(&self, extract: impl FnOnce(&[u8]) -> io::Result) -> io::Result { + let mut stream = self.stream.borrow_mut(); + let mut scratch = self.scratch.borrow_mut(); + let mut len_bytes = [0u8; 4]; + stream.read_exact(&mut len_bytes)?; + let len = u32::from_le_bytes(len_bytes) as usize; + scratch.clear(); + scratch.resize(len, 0); + stream.read_exact(&mut scratch)?; + extract(&scratch) + } +} + +#[cfg(unix)] +fn connect(name: &OsStr) -> io::Result { + std::os::unix::net::UnixStream::connect(name) +} + +/// Open a Windows named pipe as a client. +/// +/// `OpenOptions::open` on a named-pipe path fails with `ERROR_PIPE_BUSY` when +/// the server's only pending instance has just been claimed by another client +/// — the brief window between the server accepting one connection and creating +/// the next instance. On `ERROR_PIPE_BUSY` we hand off to the kernel via +/// `WaitNamedPipeW`, which blocks until an instance becomes available (or +/// fails if the named pipe is gone). No polling and no arbitrary timeouts. +/// +/// This matches what the `interprocess` crate does internally. +#[cfg(windows)] +fn connect(name: &OsStr) -> io::Result { + use std::{fs::OpenOptions, os::windows::ffi::OsStrExt}; + + use winapi::um::namedpipeapi::WaitNamedPipeW; + + // ERROR_PIPE_BUSY — see WinError.h. `std::io::Error` does not expose a + // typed constant for this, so the raw OS code is the cleanest test. + const ERROR_PIPE_BUSY: i32 = 231; + // NMPWAIT_WAIT_FOREVER — see WinBase.h. winapi 0.3 doesn't define the + // NMPWAIT_* constants yet (only the comment placeholder). + const NMPWAIT_WAIT_FOREVER: u32 = 0xFFFF_FFFF; + + // `WaitNamedPipeW` needs a NUL-terminated UTF-16 path. + let mut wide: Vec = name.encode_wide().collect(); + wide.push(0); + + loop { + match OpenOptions::new().read(true).write(true).open(name) { + Ok(file) => return Ok(file), + Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + // SAFETY: `wide` is NUL-terminated; pointer stays valid for + // the call's duration. `NMPWAIT_WAIT_FOREVER` makes this a + // bounded kernel wait (server's pipe wait-timeout is the + // upper bound on each retry; default ~50ms, then we loop). + let ok = unsafe { WaitNamedPipeW(wide.as_ptr(), NMPWAIT_WAIT_FOREVER) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // Loop and re-open — another client may have raced us to the + // newly-available instance. + } + Err(err) => return Err(err), + } + } +} + +#[expect( + clippy::disallowed_types, + reason = "std::path::PathBuf is needed to round-trip through std::fs::canonicalize on Windows below" +)] +fn resolve_path(path: &OsStr) -> io::Result> { + let absolute: std::path::PathBuf = if let Some(abs) = AbsolutePath::new(path) { + abs.as_path().to_path_buf() + } else { + let mut buf = vite_path::current_dir()?; + buf.push(path); + buf.as_path().to_path_buf() + }; + + // On Windows, canonicalize so the path uses the exact on-disk casing + // and resolves substitute drives / junctions the same way `fspy`'s + // `GetFinalPathNameByHandleW`-reported paths do. Without this, an + // `ignoreInput("cache_like")` whose `current_dir()` prefix differs in + // case or symlink shape from the fspy-reported reads won't filter + // them out, and the runner sees a read/write overlap. Strip the + // `\\?\` namespace prefix because `fspy_shared::NativePath:: + // strip_path_prefix` does the same on the runner side; if the + // canonical form starts with `\\?\UNC\`, fall back to the + // non-canonical form so we don't accidentally rewrite a UNC path + // (where dropping `\\?\` would change meaning). + #[cfg(windows)] + let absolute = std::fs::canonicalize(&absolute).map_or(absolute, |canonical| { + use std::{ + ffi::OsString, + os::windows::ffi::{OsStrExt, OsStringExt}, + }; + let wide: Vec = canonical.as_os_str().encode_wide().collect(); + let unc_prefix: Vec = r"\\?\UNC\".encode_utf16().collect(); + let nt_prefix: Vec = r"\\?\".encode_utf16().collect(); + if wide.starts_with(&unc_prefix) { + // UNC path — keep canonical form (still has \\?\UNC\ for fspy parity). + canonical + } else if let Some(rest) = wide.strip_prefix(nt_prefix.as_slice()) { + std::path::PathBuf::from(OsString::from_wide(rest)) + } else { + canonical + } + }); + + Ok(Box::::from(absolute.as_os_str())) +} diff --git a/crates/vite_task_client_napi/Cargo.toml b/crates/vite_task_client_napi/Cargo.toml new file mode 100644 index 000000000..c7d702311 --- /dev/null +++ b/crates/vite_task_client_napi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vite_task_client_napi" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib"] +test = false +doctest = false + +[dependencies] +napi = { workspace = true, features = ["napi6", "tracing"] } +napi-derive = { workspace = true } +vite_str = { workspace = true } +vite_task_client = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_task_client_napi/README.md b/crates/vite_task_client_napi/README.md new file mode 100644 index 000000000..c85a17f99 --- /dev/null +++ b/crates/vite_task_client_napi/README.md @@ -0,0 +1,3 @@ +# vite_task_client_napi + +Node addon that lets JS/TS tools running inside a `vp run` task talk to the runner over IPC via `vite_task_client`. diff --git a/crates/vite_task_client_napi/build.rs b/crates/vite_task_client_napi/build.rs new file mode 100644 index 000000000..12c9f88b3 --- /dev/null +++ b/crates/vite_task_client_napi/build.rs @@ -0,0 +1,42 @@ +#![expect( + clippy::disallowed_types, + reason = "build.rs interfaces with std::path and cargo's env-var API" +)] + +extern crate napi_build; + +use std::{env, fs, path::PathBuf}; + +fn main() { + napi_build::setup(); + + // Keep this crate's napi-derive type-defs out of any consumer's generated + // binding. + // + // `vite_task_client_napi` is embedded as a cdylib *artifact* dependency of + // `vite_task`. napi-derive's `type-def` feature is force-enabled by feature + // unification with consumers that need it (e.g. vite-plus's CLI binding), so + // disabling the feature here has no effect. By default napi-derive then + // writes this crate's `#[napi]` items (`RunnerClient`/`load`) into the + // consumer's shared `NAPI_TYPE_DEF_TMP_FOLDER`, which `@napi-rs/cli` sweeps + // into the consumer's `index.cjs`/`index.d.cts` as dead exports (the symbols + // live in the separately-loaded addon, not the consumer's `.node`). The + // public JS surface is the hand-written `@voidzero-dev/vite-task-client` + // package, so these generated defs are never needed. + // + // `@napi-rs/cli` reuses that folder across builds without pruning it, so + // first remove any entry a pre-redirect build left there, then redirect this + // crate's emission to an isolated, clearly-named sink. The override applies + // only to this crate's rustc invocation, where the napi-derive proc-macro + // reads the env at expansion time, so consumers' own type-defs are + // unaffected. + println!("cargo::rerun-if-env-changed=NAPI_TYPE_DEF_TMP_FOLDER"); + let pkg = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME not set"); + if let Ok(consumer_folder) = env::var("NAPI_TYPE_DEF_TMP_FOLDER") { + let _ = fs::remove_file(PathBuf::from(consumer_folder).join(&pkg)); + } + let sink = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")) + .join("discarded-napi-type-defs"); + fs::create_dir_all(&sink).expect("failed to create napi type-def sink dir"); + println!("cargo::rustc-env=NAPI_TYPE_DEF_TMP_FOLDER={}", sink.display()); +} diff --git a/crates/vite_task_client_napi/src/lib.rs b/crates/vite_task_client_napi/src/lib.rs new file mode 100644 index 000000000..3307d755c --- /dev/null +++ b/crates/vite_task_client_napi/src/lib.rs @@ -0,0 +1,146 @@ +//! Node addon that exposes a `load()` factory which returns a +//! `RunnerClient` JS class instance bound to the runner's IPC connection. +//! Not intended to be published directly — the runner hands the compiled +//! `.node` file to child processes via the `VP_RUN_NODE_CLIENT_PATH` env +//! var, and the JS wrapper in `@voidzero-dev/vite-task-client` +//! `require()`s it lazily. +//! +//! The factory shape (`load() -> RunnerClient`, rather than methods +//! exported at the top level) is a deliberate layer of indirection so +//! the addon can evolve over time: a future wrapper can pass an options +//! argument (e.g. a version field) and receive a differently-shaped +//! addon, without breaking older addons that ignore the argument. +//! +//! `load()` is callable only inside a runner-spawned task: when the IPC +//! env is absent or the connection refuses, `load()` throws and the JS +//! wrapper falls into no-op mode. + +// The napi boundary forces std `String` through function signatures; clippy's +// blanket bans on disallowed types / needless-pass-by-value / missing Errors +// sections are all about pure-Rust call sites and don't apply here (JS never +// reads rustdoc). `disallowed_macros` is allowed because `napi-derive` expands +// to `std::format!` inside `check_status!`, and the macro output isn't ours +// to rewrite. +#![expect( + clippy::disallowed_macros, + clippy::disallowed_types, + clippy::missing_errors_doc, + clippy::needless_pass_by_value, + reason = "napi bindings require owned std String + std::format! at the JS boundary" +)] + +use std::{collections::HashMap, ffi::OsStr}; + +use napi::{Error, Result}; +use napi_derive::napi; +use vite_task_client::Client; + +/// Options for [`RunnerClient::get_env`] and [`RunnerClient::get_envs`]. +/// +/// Modeled as a JS plain object rather than a positional boolean so future +/// knobs (e.g. a `default` value) can be added without an ABI break on the +/// JS wrapper side. +/// +/// Every field is optional so the napi addon — the cross-version API +/// stability boundary between the runner-shipped `.node` and the +/// separately-npm-published JS wrapper — can fill in defaults and let old +/// wrappers keep working against new runners (and vice versa). +#[napi(object)] +pub struct GetEnvOptions { + /// Whether the runner should record this env as a cache-key dependency. + /// Defaults to `true`. + pub tracked: Option, +} + +/// Handle returned by [`load`]. Holds the IPC connection and exposes the +/// runner-side operations as instance methods. +#[napi] +pub struct RunnerClient { + client: Client, +} + +#[napi] +impl RunnerClient { + #[napi] + pub fn ignore_input(&self, path: String) -> Result<()> { + self.client + .ignore_input(OsStr::new(&path)) + .map_err(|err| err_string(vite_str::format!("{err}"))) + } + + #[napi] + pub fn ignore_output(&self, path: String) -> Result<()> { + self.client + .ignore_output(OsStr::new(&path)) + .map_err(|err| err_string(vite_str::format!("{err}"))) + } + + #[napi] + pub fn disable_cache(&self) -> Result<()> { + self.client.disable_cache().map_err(|err| err_string(vite_str::format!("{err}"))) + } + + #[napi] + pub fn get_env(&self, name: String, options: Option) -> Result> { + let tracked = options.and_then(|o| o.tracked).unwrap_or(true); + let value = self + .client + .get_env(OsStr::new(&name), tracked) + .map_err(|err| err_string(vite_str::format!("{err}")))?; + value.map_or(Ok(None), |value| { + value.to_str().map(|s| Some(s.to_owned())).ok_or_else(|| { + err_string(vite_str::format!("env value for {name} is not valid UTF-8")) + }) + }) + } + + #[napi] + pub fn get_envs( + &self, + pattern: String, + options: Option, + ) -> Result> { + let tracked = options.and_then(|o| o.tracked).unwrap_or(true); + let matches = self + .client + .get_envs(&pattern, tracked) + .map_err(|err| err_string(vite_str::format!("{err}")))?; + // Entries whose name or value contains non-UTF-8 bytes can't cross + // the JS boundary as `String`. Unlike `get_env` (which errors out), + // bulk fetch drops them silently — the caller has no way to know + // which one is bad, and a partial match-set is usually still useful. + Ok(matches + .into_iter() + .filter_map(|(k, v)| Some((k.to_str()?.to_owned(), v.to_str()?.to_owned()))) + .collect()) + } +} + +/// Connect to the runner and return a [`RunnerClient`]. Throws when the +/// IPC env is missing or the connection fails. +#[napi] +pub fn load() -> Result { + #[expect( + clippy::disallowed_methods, + reason = "client bootstrap reads the live process env to find runner IPC handoff" + )] + let client = Client::from_envs(std::env::vars_os()) + .map_err(|err| { + err_string(vite_str::format!("vp run client: failed to connect to runner IPC: {err}")) + })? + .ok_or_else(|| { + err_static( + "vp run client: runner IPC env is not set; this module is only usable \ + inside a `vp run` task", + ) + })?; + Ok(RunnerClient { client }) +} + +fn err_static(msg: &'static str) -> Error { + Error::from_reason(msg) +} + +fn err_string(msg: vite_str::Str) -> Error { + Error::from_reason(msg.as_str()) +} diff --git a/crates/vite_task_ipc_shared/.clippy.toml b/crates/vite_task_ipc_shared/.clippy.toml new file mode 120000 index 000000000..c7929b369 --- /dev/null +++ b/crates/vite_task_ipc_shared/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/vite_task_ipc_shared/Cargo.toml b/crates/vite_task_ipc_shared/Cargo.toml new file mode 100644 index 000000000..bcc90d441 --- /dev/null +++ b/crates/vite_task_ipc_shared/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vite_task_ipc_shared" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +native_str = { workspace = true } +rustc-hash = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_ipc_shared/README.md b/crates/vite_task_ipc_shared/README.md new file mode 100644 index 000000000..26116e79a --- /dev/null +++ b/crates/vite_task_ipc_shared/README.md @@ -0,0 +1,3 @@ +# vite_task_ipc_shared + +Shared IPC message types for communication between the task runner and tools. diff --git a/crates/vite_task_ipc_shared/src/lib.rs b/crates/vite_task_ipc_shared/src/lib.rs new file mode 100644 index 000000000..561b18c47 --- /dev/null +++ b/crates/vite_task_ipc_shared/src/lib.rs @@ -0,0 +1,50 @@ +use native_str::NativeStr; +use rustc_hash::FxHashMap; +use wincode::{SchemaRead, SchemaWrite}; + +pub const IPC_ENV_NAME: &str = "VP_RUN_IPC_NAME"; + +/// Path to the Node client module that JS/TS tools `require()` to talk to +/// the runner. +/// +/// Implementation-detail leakage (`napi`, `.node`, `addon`) is intentionally +/// kept out of the name: from the consumer's point of view this is just a +/// path they can `require()`. The `NODE_` scope reserves room for a future +/// C-ABI client library advertised via its own env var for non-Node +/// consumers. +pub const NODE_CLIENT_PATH_ENV_NAME: &str = "VP_RUN_NODE_CLIENT_PATH"; + +/// IPC request frame sent by tools to the runner. +/// +/// `IgnoreInput`, `IgnoreOutput`, and `DisableCache` are fire-and-forget: +/// the runner processes them when they arrive and never writes a response. +/// `GetEnv` and `GetEnvs` are round-trips and pair with the matching response +/// types below. +/// +/// Fire-and-forget is safe because nothing in the runner observes individual +/// IPC events live — the recorded set is only consumed *after* the per-task +/// IPC driver has drained the connection, which happens after the child +/// process exits. So a tool can `flush + exit` and the server's drain phase +/// will still consume every buffered frame. +#[derive(Debug, SchemaWrite, SchemaRead)] +pub enum Request<'a> { + IgnoreInput(&'a NativeStr), + IgnoreOutput(&'a NativeStr), + GetEnv { name: &'a NativeStr, tracked: bool }, + GetEnvs { pattern: &'a str, tracked: bool }, + DisableCache, +} + +#[derive(Debug, SchemaWrite, SchemaRead)] +pub struct GetEnvResponse<'a> { + pub env_value: Option<&'a NativeStr>, +} + +#[derive(Debug, SchemaWrite, SchemaRead)] +pub struct GetEnvsResponse<'a> { + /// Match snapshot for the glob pattern. Keys/values are byte-faithful + /// (`NativeStr`) — non-UTF-8 env values are preserved over the wire so the + /// runner can record them unchanged. Conversion to `str` happens later, + /// at the cache-fingerprinting boundary, not here. + pub entries: FxHashMap<&'a NativeStr, &'a NativeStr>, +} diff --git a/crates/vite_task_server/Cargo.toml b/crates/vite_task_server/Cargo.toml new file mode 100644 index 000000000..94f1bad8d --- /dev/null +++ b/crates/vite_task_server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "vite_task_server" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +futures = { workspace = true } +native_str = { workspace = true } +rustc-hash = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["io-util", "net", "rt", "macros"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +vite_glob = { workspace = true } +vite_path = { workspace = true } +vite_task_ipc_shared = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[target.'cfg(windows)'.dependencies] +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "net", "rt", "macros", "time"] } +vite_task_client = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_server/README.md b/crates/vite_task_server/README.md new file mode 100644 index 000000000..cdcbdcddf --- /dev/null +++ b/crates/vite_task_server/README.md @@ -0,0 +1,3 @@ +# vite_task_server + +IPC server that runs per task execution, receiving messages from tools (runner-aware tools) and dispatching them to a user-provided handler. diff --git a/crates/vite_task_server/src/lib.rs b/crates/vite_task_server/src/lib.rs new file mode 100644 index 000000000..c3a7ae9ed --- /dev/null +++ b/crates/vite_task_server/src/lib.rs @@ -0,0 +1,475 @@ +use std::{ + cell::RefCell, + ffi::{OsStr, OsString}, + io, + sync::Arc, +}; + +use futures::{FutureExt, StreamExt, future::LocalBoxFuture, stream::FuturesUnordered}; +use native_str::NativeStr; +use rustc_hash::{FxHashMap, FxHashSet}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; +use vite_path::AbsolutePath; +use vite_task_ipc_shared::{GetEnvResponse, GetEnvsResponse, IPC_ENV_NAME, Request}; +use wincode::{SchemaWrite, config::DefaultConfig}; + +pub trait Handler { + fn ignore_input(&mut self, path: &Arc); + fn ignore_output(&mut self, path: &Arc); + fn disable_cache(&mut self); + fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option>; + /// Returns the env names whose names match `pattern`. + /// + /// # Errors + /// + /// Returns an error if `pattern` fails to parse as a glob. + fn get_envs( + &mut self, + pattern: &str, + tracked: bool, + ) -> Result, Arc>, vite_glob::env::EnvGlobError>; +} + +/// A protocol-level failure observed while servicing a client. +/// +/// The driver retains only the first such error across all clients, then +/// completes gracefully (existing clients drain, new connections are rejected). +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to read request frame from client")] + ReadFrame(#[source] io::Error), + + #[error("failed to deserialize request from client")] + InvalidRequest(#[source] wincode::ReadError), + + #[error("non-absolute path from client: {path:?}")] + NonAbsolutePath { path: OsString }, + + #[error("invalid glob pattern from client: {:?}", .0.pattern)] + InvalidGlob(Box), + + #[error("failed to write response to client")] + WriteResponse(#[source] io::Error), +} + +/// Payload for [`Error::InvalidGlob`]. Boxed so the `Error` enum stays small +/// — `vite_glob::env::EnvGlobError` wraps `globset::Error`, which is large. +#[derive(Debug)] +pub struct InvalidGlob { + pub pattern: Box, + pub source: vite_glob::env::EnvGlobError, +} + +/// A [`Handler`] that records every report. +/// +/// `get_env` and `get_envs` keep protocol compatibility but deliberately do +/// not serve runner env values yet; callers fall back to their own process env +/// until env tracking lands. +/// +/// Call [`Recorder::into_reports`] after the driver future completes to +/// recover the collected [`Reports`]. +pub struct Recorder { + ignored_inputs: FxHashSet>, + ignored_outputs: FxHashSet>, + cache_disabled: bool, + env_records: FxHashMap, EnvRecord>, + env_glob_records: FxHashMap, EnvGlobRecord>, +} + +/// A record of an env value requested via `get_env`. +/// +/// `tracked` is the monotonic OR of every `tracked` flag sent for this name +/// — once `true`, it stays `true`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvRecord { + pub tracked: bool, + pub value: Option>, +} + +/// A record of a glob-pattern env query made via `get_envs`. +/// +/// `matches` is captured on the first call and reused on repeat queries. +/// In this slice the set is always empty; env value serving and fingerprint +/// tracking land in the cache layer follow-up. `tracked` is monotonic like +/// `EnvRecord::tracked`. +/// +/// Names and values are stored as `OsStr` here — the in-memory recording +/// layer is byte-faithful. Conversion to `str` happens at the fingerprint +/// boundary downstream; conversion to `NativeStr` happens at the wire +/// boundary inside the per-client request handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvGlobRecord { + pub tracked: bool, + pub matches: FxHashMap, Arc>, +} + +/// The data collected by a [`Recorder`] over the server's lifetime. +#[derive(Debug, Default)] +pub struct Reports { + pub ignored_inputs: FxHashSet>, + pub ignored_outputs: FxHashSet>, + pub cache_disabled: bool, + pub env_records: FxHashMap, EnvRecord>, + pub env_glob_records: FxHashMap, EnvGlobRecord>, +} + +impl Recorder { + #[must_use] + pub fn new() -> Self { + Self { + ignored_inputs: FxHashSet::default(), + ignored_outputs: FxHashSet::default(), + cache_disabled: false, + env_records: FxHashMap::default(), + env_glob_records: FxHashMap::default(), + } + } + + #[must_use] + pub fn into_reports(self) -> Reports { + Reports { + ignored_inputs: self.ignored_inputs, + ignored_outputs: self.ignored_outputs, + cache_disabled: self.cache_disabled, + env_records: self.env_records, + env_glob_records: self.env_glob_records, + } + } +} + +impl Default for Recorder { + fn default() -> Self { + Self::new() + } +} + +impl Handler for Recorder { + fn ignore_input(&mut self, path: &Arc) { + self.ignored_inputs.insert(Arc::clone(path)); + } + + fn ignore_output(&mut self, path: &Arc) { + self.ignored_outputs.insert(Arc::clone(path)); + } + + fn disable_cache(&mut self) { + self.cache_disabled = true; + } + + fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option> { + if let Some(existing) = self.env_records.get_mut(name) { + existing.tracked |= tracked; + return None; + } + self.env_records.insert(name.into(), EnvRecord { tracked, value: None }); + None + } + + fn get_envs( + &mut self, + pattern: &str, + tracked: bool, + ) -> Result, Arc>, vite_glob::env::EnvGlobError> { + if let Some(existing) = self.env_glob_records.get_mut(pattern) { + existing.tracked |= tracked; + return Ok(FxHashMap::default()); + } + let _glob = vite_glob::env::EnvGlob::new(pattern)?; + self.env_glob_records + .insert(Arc::from(pattern), EnvGlobRecord { tracked, matches: FxHashMap::default() }); + Ok(FxHashMap::default()) + } +} + +/// Handle to a running IPC server. +/// +/// `driver` must be polled to accept clients and handle messages. It resolves +/// only after [`StopAccepting::signal`] has been called AND all in-flight +/// per-client tasks have drained, returning the owned handler. +/// +/// The driver resolves to `Err(Error)` if any client triggered a protocol +/// violation (see [`Error`]). The first such error is retained; subsequent +/// errors during drain are discarded. On `Err`, the handler is not returned. +/// +/// Dropping `driver` before it resolves tears everything down immediately — +/// listener closed, per-client tasks cancelled, handler discarded. +pub struct ServerHandle<'h, H> { + pub driver: LocalBoxFuture<'h, Result>, + pub stop_accepting: StopAccepting, +} + +/// Signal that tells the server to stop accepting new clients. Existing +/// clients continue until they naturally close the connection; the driver +/// future resolves once that drain completes. +/// +/// [`signal`](Self::signal) takes `&self` and the underlying cancellation +/// is idempotent, so calling it twice or from a shared borrow is safe. +pub struct StopAccepting { + token: CancellationToken, +} + +impl StopAccepting { + /// A no-op `StopAccepting` not bound to any running server. Signalling it + /// is a no-op. Useful for placeholder paths where the runner hasn't wired + /// the server in yet but still needs a value of this type. + #[must_use] + pub fn noop() -> Self { + Self { token: CancellationToken::new() } + } + + pub fn signal(&self) { + self.token.cancel(); + } +} + +/// Starts an IPC server. +/// +/// Returns the env entries that a child process must inherit to find and +/// connect to this server, plus a handle bundling the driver future and the +/// `StopAccepting` signal. See [`ServerHandle`] for driver semantics. +/// +/// # Errors +/// +/// Returns an error if creating the listener fails (on Unix, this includes +/// creating the temp socket path). +pub fn serve<'h, H: Handler + 'h>( + handler: H, +) -> io::Result<(impl Iterator, ServerHandle<'h, H>)> { + let stop_token = CancellationToken::new(); + let (name, bound) = bind_listener()?; + + let run_stop = stop_token.clone(); + let driver = async move { + // Multiple per-client futures coexist inside `FuturesUnordered` and each + // calls `&mut self` handler methods. `RefCell` provides the interior + // mutability that makes these shared-access method calls compile; at + // runtime the `borrow_mut()` never conflicts because we're on a + // single-threaded runtime and handler methods are synchronous (no + // awaits, so no borrow spans a yield point). + let handler = RefCell::new(handler); + let first_err = run(bound, &handler, run_stop).await; + first_err.map_or_else(|| Ok(handler.into_inner()), Err) + } + .boxed_local(); + + Ok(( + std::iter::once((OsStr::new(IPC_ENV_NAME), name)), + ServerHandle { driver, stop_accepting: StopAccepting { token: stop_token } }, + )) +} + +#[cfg(unix)] +type Stream = tokio::net::UnixStream; +#[cfg(windows)] +type Stream = tokio::net::windows::named_pipe::NamedPipeServer; + +/// The bound listener for the IPC server. +/// +/// Unix: a Tokio [`UnixListener`](tokio::net::UnixListener) bound inside a +/// [`NamedTempFile`](tempfile::NamedTempFile) so its socket file is unlinked +/// on `Drop`. Windows: a single named-pipe instance that is created up front +/// and replaced on each `accept` (a new pipe instance must be created before +/// the previous one is handed to the client, otherwise concurrent connect +/// attempts race for it). +#[cfg(unix)] +struct Bound { + file: tempfile::NamedTempFile, +} + +#[cfg(windows)] +struct Bound { + pipe_name: OsString, + pending: tokio::net::windows::named_pipe::NamedPipeServer, +} + +#[cfg(unix)] +fn bind_listener() -> io::Result<(OsString, Bound)> { + // `make` lets us bind the socket directly to the path tempfile picks; the + // closure is responsible for creating the file (`UnixListener::bind` does). + // The `NamedTempFile` wrapper unlinks the socket path on `Drop`. + let file = tempfile::Builder::new() + .prefix("vite_task_ipc_") + .make(|path| tokio::net::UnixListener::bind(path))?; + let name = file.path().as_os_str().to_owned(); + Ok((name, Bound { file })) +} + +#[cfg(windows)] +fn bind_listener() -> io::Result<(OsString, Bound)> { + use tokio::net::windows::named_pipe::ServerOptions; + + #[expect( + clippy::disallowed_macros, + reason = "pipe name always exceeds Str inline capacity; format! is the simplest construction" + )] + let pipe_name = OsString::from(format!(r"\\.\pipe\vite_task_ipc_{}", uuid::Uuid::new_v4())); + let pending = ServerOptions::new().first_pipe_instance(true).create(&pipe_name)?; + Ok((pipe_name.clone(), Bound { pipe_name, pending })) +} + +impl Bound { + #[cfg(unix)] + #[expect( + clippy::needless_pass_by_ref_mut, + reason = "Windows variant requires &mut self to swap pending instance; keep the signature uniform across cfgs so `run` can call it identically." + )] + async fn accept(&mut self) -> io::Result { + let (stream, _addr) = self.file.as_file().accept().await?; + Ok(stream) + } + + #[cfg(windows)] + async fn accept(&mut self) -> io::Result { + use tokio::net::windows::named_pipe::ServerOptions; + + // Wait for the next client to connect to the currently-pending + // instance, then immediately create a fresh instance to listen for the + // connection after that. Creating the next instance before yielding the + // accepted one ensures no client gets `ERROR_PIPE_BUSY` during the + // handoff. + self.pending.connect().await?; + let next = ServerOptions::new().create(&self.pipe_name)?; + Ok(std::mem::replace(&mut self.pending, next)) + } +} + +async fn run( + mut bound: Bound, + handler: &RefCell, + shutdown: CancellationToken, +) -> Option { + let mut clients = FuturesUnordered::new(); + let mut first_err: Option = None; + + // Accept phase: accept new clients until shutdown fires. + loop { + tokio::select! { + () = shutdown.cancelled() => break, + accept_result = bound.accept() => { + match accept_result { + Ok(stream) => { + clients.push(handle_client(stream, handler).boxed_local()); + } + Err(err) => { + tracing::warn!(?err, "vite_task_server: accept failed"); + } + } + } + Some(result) = clients.next(), if !clients.is_empty() => { + if let Err(err) = result + && first_err.is_none() + { + first_err = Some(err); + shutdown.cancel(); + } + } + } + } + + // Stop accepting: drop the listener (and on Unix unlink the socket file). + // Existing client streams continue to work. + drop(bound); + + // Drain phase: wait for all in-flight per-client tasks to finish. + while let Some(result) = clients.next().await { + if let Err(err) = result + && first_err.is_none() + { + first_err = Some(err); + } + } + + first_err +} + +async fn handle_client(mut stream: Stream, handler: &RefCell) -> Result<(), Error> { + let mut buf = Vec::new(); + loop { + match read_frame(&mut stream, &mut buf).await { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(()), + Err(err) => return Err(Error::ReadFrame(err)), + } + + let request: Request<'_> = + wincode::deserialize_exact(&buf).map_err(Error::InvalidRequest)?; + + // Fire-and-forget branches (`IgnoreInput`, `IgnoreOutput`, + // `DisableCache`) intentionally write no response. Nothing in the + // runner observes individual IPC events live; the recorded set is + // collected after this driver drains. See `Request` in + // `vite_task_ipc_shared` for the rationale. + match request { + Request::IgnoreInput(ns) => { + let path = native_str_to_abs_path(ns)?; + handler.borrow_mut().ignore_input(&path); + } + Request::IgnoreOutput(ns) => { + let path = native_str_to_abs_path(ns)?; + handler.borrow_mut().ignore_output(&path); + } + Request::DisableCache => { + handler.borrow_mut().disable_cache(); + } + Request::GetEnv { name, tracked } => { + let value = handler.borrow_mut().get_env(name.to_cow_os_str().as_ref(), tracked); + let boxed: Option> = value.as_deref().map(Into::into); + let response = GetEnvResponse { env_value: boxed.as_deref() }; + write_response(&mut stream, &response).await.map_err(Error::WriteResponse)?; + } + Request::GetEnvs { pattern, tracked } => { + let matches = + handler.borrow_mut().get_envs(pattern, tracked).map_err(|source| { + Error::InvalidGlob(Box::new(InvalidGlob { + pattern: Box::::from(pattern), + source, + })) + })?; + // Wire boundary: convert the handler's `OsStr` map into the + // byte-faithful `NativeStr` form. `boxed_entries` owns the + // `NativeStr` boxes so their borrowed refs stay valid while + // `response` is serialized. + let boxed_entries: Vec<(Box, Box)> = matches + .iter() + .map(|(k, v)| (Box::::from(&**k), Box::::from(&**v))) + .collect(); + let entries: FxHashMap<&NativeStr, &NativeStr> = + boxed_entries.iter().map(|(k, v)| (&**k, &**v)).collect(); + let response = GetEnvsResponse { entries }; + write_response(&mut stream, &response).await.map_err(Error::WriteResponse)?; + } + } + } +} + +fn native_str_to_abs_path(ns: &NativeStr) -> Result, Error> { + let os_str = ns.to_cow_os_str(); + AbsolutePath::new(&*os_str) + .map(Arc::from) + .ok_or_else(|| Error::NonAbsolutePath { path: os_str.into_owned() }) +} + +async fn read_frame(stream: &mut Stream, buf: &mut Vec) -> io::Result<()> { + let mut len_bytes = [0u8; 4]; + stream.read_exact(&mut len_bytes).await?; + let len = u32::from_le_bytes(len_bytes) as usize; + buf.clear(); + buf.resize(len, 0); + stream.read_exact(buf).await?; + Ok(()) +} + +async fn write_response(stream: &mut Stream, response: &T) -> io::Result<()> +where + T: SchemaWrite + ?Sized, +{ + let bytes = wincode::serialize(response) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let len = u32::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "response too large"))?; + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} diff --git a/crates/vite_task_server/tests/integration.rs b/crates/vite_task_server/tests/integration.rs new file mode 100644 index 000000000..38f186574 --- /dev/null +++ b/crates/vite_task_server/tests/integration.rs @@ -0,0 +1,285 @@ +use std::{ + ffi::{OsStr, OsString}, + io::{self, Read, Write}, + sync::Arc, + thread, +}; + +use native_str::NativeStr; + +#[cfg(unix)] +type RawStream = std::os::unix::net::UnixStream; +#[cfg(windows)] +type RawStream = std::fs::File; +use rustc_hash::FxHashMap; +use tokio::runtime::Builder; +use vite_task_client::Client; +use vite_task_ipc_shared::Request; +use vite_task_server::{Error, Recorder, Reports, ServerHandle, serve}; + +fn env_map(pairs: &[(&str, &str)]) -> FxHashMap, Arc> { + pairs + .iter() + .map(|(k, v)| (Arc::::from(OsStr::new(k)), Arc::::from(OsStr::new(v)))) + .collect() +} + +fn run_with_server( + _envs: FxHashMap, Arc>, + client_work: F, +) -> Result +where + F: FnOnce(Vec<(&'static OsStr, OsString)>) + Send + 'static, +{ + let recorder = Recorder::new(); + + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(async move { + let (envs, ServerHandle { driver, stop_accepting }) = serve(recorder).expect("bind server"); + let envs: Vec<_> = envs.collect(); + + let client = async move { + tokio::task::spawn_blocking(move || client_work(envs)) + .await + .expect("client work panicked"); + stop_accepting.signal(); + }; + + let (result, ()) = tokio::join!(driver, client); + result.map(Recorder::into_reports) + }) +} + +fn connect(envs: &[(&'static OsStr, OsString)]) -> Client { + Client::from_envs(envs.iter().map(|(k, v)| (k, v))) + .expect("connect") + .expect("serve should yield an IPC env") +} + +/// Force a round-trip so the server has definitely processed every prior +/// fire-and-forget frame on this connection: frames on a single stream are +/// read sequentially, so once the server answers a `get_env` everything +/// before it must already have been dispatched to the handler. +fn flush(client: &Client) { + let _ = client.get_env(OsStr::new("__VP_TEST_FLUSH__"), false).unwrap(); +} + +#[cfg(unix)] +fn connect_raw(name: &OsStr) -> RawStream { + std::os::unix::net::UnixStream::connect(name).expect("connect raw") +} + +#[cfg(windows)] +fn connect_raw(name: &OsStr) -> RawStream { + std::fs::OpenOptions::new().read(true).write(true).open(name).expect("connect raw") +} + +fn send_frame(stream: &mut RawStream, request: &Request<'_>) { + let bytes = wincode::serialize(request).expect("serialize"); + let len = u32::try_from(bytes.len()).expect("frame length fits u32"); + stream.write_all(&len.to_le_bytes()).expect("write len"); + stream.write_all(&bytes).expect("write body"); + stream.flush().expect("flush"); +} + +#[test] +fn single_client_fire_and_forget() { + // Absolute paths look different on each platform; bare forward-slash + // paths are relative on Windows (no drive letter) and would be rewritten + // by the client before the server sees them. + #[cfg(unix)] + let (in_path, out_path) = ("/tmp/in.txt", "/tmp/out.txt"); + #[cfg(windows)] + let (in_path, out_path) = (r"C:\tmp\in.txt", r"C:\tmp\out.txt"); + + let reports = run_with_server(env_map(&[]), |envs| { + let client = connect(&envs); + client.ignore_input(OsStr::new(in_path)).unwrap(); + client.ignore_output(OsStr::new(out_path)).unwrap(); + client.disable_cache().unwrap(); + flush(&client); + }) + .expect("driver returned error"); + + let inputs: Vec<_> = reports.ignored_inputs.iter().map(|p| p.as_path().as_os_str()).collect(); + let outputs: Vec<_> = reports.ignored_outputs.iter().map(|p| p.as_path().as_os_str()).collect(); + assert_eq!(inputs, vec![OsStr::new(in_path)]); + assert_eq!(outputs, vec![OsStr::new(out_path)]); + assert!(reports.cache_disabled); +} + +#[test] +fn get_env_found_and_not_found() { + let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |envs| { + let client = connect(&envs); + let present = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); + assert!(present.is_none()); + let missing = client.get_env(OsStr::new("MISSING"), false).unwrap(); + assert!(missing.is_none()); + }) + .expect("driver returned error"); + + let node = reports.env_records.get(OsStr::new("NODE_ENV")).expect("NODE_ENV recorded"); + assert!(node.tracked); + assert!(node.value.is_none()); + + let missing = reports.env_records.get(OsStr::new("MISSING")).expect("MISSING recorded"); + assert!(!missing.tracked); + assert!(missing.value.is_none()); +} + +#[test] +fn get_env_tracked_upgrade_is_monotonic() { + let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |envs| { + let client = connect(&envs); + let a = client.get_env(OsStr::new("NODE_ENV"), false).unwrap(); + let b = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); + let c = client.get_env(OsStr::new("NODE_ENV"), false).unwrap(); + for v in [a, b, c] { + assert!(v.is_none()); + } + }) + .expect("driver returned error"); + + let node = reports.env_records.get(OsStr::new("NODE_ENV")).expect("recorded"); + assert!(node.tracked, "tracked must remain true once set"); +} + +#[test] +fn concurrent_clients() { + #[cfg(unix)] + let paths = ["/tmp/worker_0", "/tmp/worker_1", "/tmp/worker_2", "/tmp/worker_3"]; + #[cfg(windows)] + let paths = [r"C:\tmp\worker_0", r"C:\tmp\worker_1", r"C:\tmp\worker_2", r"C:\tmp\worker_3"]; + let reports = run_with_server(env_map(&[("SHARED", "value")]), move |envs| { + let threads: Vec<_> = paths + .iter() + .map(|path| { + let envs = envs.clone(); + let path = *path; + thread::spawn(move || { + let client = connect(&envs); + client.ignore_input(OsStr::new(path)).unwrap(); + let value = client.get_env(OsStr::new("SHARED"), true).unwrap(); + assert!(value.is_none()); + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + }) + .expect("driver returned error"); + + assert_eq!(reports.ignored_inputs.len(), 4); + let shared = reports.env_records.get(OsStr::new("SHARED")).expect("recorded"); + assert!(shared.tracked); + assert!(shared.value.is_none()); +} + +#[test] +fn relative_input_joined_with_cwd() { + let cwd = vite_path::current_dir().expect("cwd"); + let expected = cwd.as_path().join("sub/file.txt"); + + let reports = run_with_server(env_map(&[]), |envs| { + let client = connect(&envs); + client.ignore_input(OsStr::new("sub/file.txt")).unwrap(); + flush(&client); + }) + .expect("driver returned error"); + + let inputs: Vec<_> = reports.ignored_inputs.iter().map(|p| p.as_path().as_os_str()).collect(); + assert_eq!(inputs, vec![expected.as_os_str()]); +} + +#[test] +fn server_returns_error_on_non_absolute_path() { + let err = run_with_server(env_map(&[]), |envs| { + let name = &envs[0].1; + let mut stream = connect_raw(name); + + let ns: Box = OsStr::new("relative/path").into(); + send_frame(&mut stream, &Request::IgnoreInput(&ns)); + + let mut buf = [0u8; 1]; + let read_err = stream.read_exact(&mut buf).expect_err("server should close connection"); + assert_eq!(read_err.kind(), io::ErrorKind::UnexpectedEof); + }) + .expect_err("driver should surface the protocol error"); + + match err { + Error::NonAbsolutePath { path } => { + assert_eq!(path, OsStr::new("relative/path")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[test] +fn get_envs_returns_empty_match_set() { + let reports = run_with_server( + env_map(&[("PROBE_A", "alpha"), ("PROBE_B", "beta"), ("UNRELATED", "noise")]), + |envs| { + let client = connect(&envs); + let matches = client.get_envs("PROBE_*", true).unwrap(); + assert!(matches.is_empty()); + }, + ) + .expect("driver returned error"); + + let glob = reports.env_glob_records.get("PROBE_*").expect("glob recorded"); + assert!(glob.tracked); + assert!(glob.matches.is_empty()); +} + +#[test] +fn get_envs_empty_match_set_is_recorded() { + let reports = run_with_server(env_map(&[("FOO", "x"), ("BAR", "y")]), |envs| { + let client = connect(&envs); + let matches = client.get_envs("PROBE_*", true).unwrap(); + assert!(matches.is_empty()); + }) + .expect("driver returned error"); + + let glob = reports.env_glob_records.get("PROBE_*").expect("glob recorded"); + assert!(glob.tracked); + assert!(glob.matches.is_empty()); +} + +#[test] +fn get_envs_tracked_upgrade_is_monotonic() { + let reports = run_with_server(env_map(&[("PROBE_A", "alpha")]), |envs| { + let client = connect(&envs); + let first = client.get_envs("PROBE_*", false).unwrap(); + let second = client.get_envs("PROBE_*", true).unwrap(); + let third = client.get_envs("PROBE_*", false).unwrap(); + // Same snapshot returned on every call. + assert_eq!(first, second); + assert_eq!(second, third); + }) + .expect("driver returned error"); + + let glob = reports.env_glob_records.get("PROBE_*").expect("glob recorded"); + assert!(glob.tracked, "tracked must stay true once set"); + assert!(glob.matches.is_empty()); +} + +#[test] +fn get_envs_invalid_pattern_surfaces_error() { + let err = run_with_server(env_map(&[]), |envs| { + let client = connect(&envs); + // Unbalanced alternation — wax rejects this as a build error. + let send_err = client.get_envs("{unclosed", true).expect_err("server should reject"); + // The server closes the stream after sending the error; reads return EOF. + assert_eq!(send_err.kind(), io::ErrorKind::UnexpectedEof); + }) + .expect_err("driver should surface the protocol error"); + + match err { + Error::InvalidGlob(inner) => { + assert_eq!(inner.pattern.as_ref(), "{unclosed"); + } + other => panic!("unexpected error variant: {other:?}"), + } +} diff --git a/packages/tools/package.json b/packages/tools/package.json index 06d48c7f0..1085e76d0 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -6,6 +6,7 @@ "cross-env": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", - "oxlint-tsgolint": "catalog:" + "oxlint-tsgolint": "catalog:", + "vite": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ff81249f..a097b6ca3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,7 @@ catalogs: overrides: vite: npm:@voidzero-dev/vite-plus-core@^0.1.23 vitest: npm:@voidzero-dev/vite-plus-test@^0.1.23 + vite-task-tools>vite: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68 importers: @@ -56,7 +57,7 @@ importers: version: 0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3) vitest: specifier: npm:@voidzero-dev/vite-plus-test@^0.1.23 - version: '@voidzero-dev/vite-plus-test@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-test@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))' packages/tools: dependencies: @@ -68,18 +69,36 @@ importers: version: 0.42.0 oxlint: specifier: 'catalog:' - version: 1.67.0(oxlint-tsgolint@0.18.1)(vite-plus@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)) + version: 1.67.0(oxlint-tsgolint@0.18.1)(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))) oxlint-tsgolint: specifier: 'catalog:' version: 0.18.1 + vite: + specifier: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68 + version: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3) packages/vite-task-client: {} packages: + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@oxc-project/runtime@0.133.0': resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -520,12 +539,113 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@tsconfig/strictest@2.0.8': resolution: {integrity: sha512-XnQ7vNz5HRN0r88GYf1J9JJjqtZPiHt2woGJOo2dYqyHGGcd6OLGqSlBB6p1j9mpzja6Oe5BoPqWmeDx6X9rLw==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -640,6 +760,7 @@ packages: '@voidzero-dev/vite-plus-test@0.1.23': resolution: {integrity: sha512-50NmnIMHsES5f+4iScEwqAR6LlsE1oP7n1HBxaYVX839tjMWCYHRUiBlBZFU+OoWwuFNq0I1ap0j0vamvJsYGg==} + version: 0.1.23 engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@edge-runtime/vm': '*' @@ -681,6 +802,10 @@ packages: cpu: [x64] os: [win32] + '@voidzero-dev/vite-task-client@0.1.1': + resolution: {integrity: sha512-9zGnSzvzUOKNoMf4zxaDeAyerkvnsXBRWfbTkwhwHsVJFug3j48Et8kr+ulHf3KRdcLr07Np+xDuCvHYVK1k7w==} + engines: {node: '>=18'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -866,6 +991,11 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -892,8 +1022,8 @@ packages: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@2.1.0: @@ -904,6 +1034,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -917,6 +1050,50 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68: + resolution: {tarball: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68} + version: 8.0.16 + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -936,8 +1113,31 @@ packages: snapshots: + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@epic-web/invariant@1.0.0': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@oxc-project/runtime@0.133.0': {} '@oxc-project/types@0.133.0': {} @@ -1153,10 +1353,66 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tsconfig/strictest@2.0.8': {} + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1197,7 +1453,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.23': optional: true - '@voidzero-dev/vite-plus-test@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)': + '@voidzero-dev/vite-plus-test@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -1210,8 +1466,8 @@ snapshots: std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 - vite: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3)' + tinyglobby: 0.2.17 + vite: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3) ws: 8.21.0 optionalDependencies: '@types/node': 25.0.3 @@ -1243,6 +1499,8 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.23': optional: true + '@voidzero-dev/vite-task-client@0.1.1': {} + assertion-error@2.0.1: {} cross-env@10.1.0: @@ -1373,6 +1631,32 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.52.0 vite-plus: 0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3) + oxfmt@0.52.0(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)) + optional: true + oxlint-tsgolint@0.18.1: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.18.1 @@ -1391,7 +1675,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.23.0 '@oxlint-tsgolint/win32-x64': 0.23.0 - oxlint@1.67.0(oxlint-tsgolint@0.18.1)(vite-plus@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)): + oxlint@1.67.0(oxlint-tsgolint@0.18.1)(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 '@oxlint/binding-android-arm64': 1.67.0 @@ -1413,7 +1697,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.67.0 '@oxlint/binding-win32-x64-msvc': 1.67.0 oxlint-tsgolint: 0.18.1 - vite-plus: 0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3) + vite-plus: 0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)) oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)): optionalDependencies: @@ -1439,6 +1723,31 @@ snapshots: oxlint-tsgolint: 0.23.0 vite-plus: 0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)) + optional: true + path-key@3.1.1: {} picocolors@1.1.1: {} @@ -1457,6 +1766,27 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1477,7 +1807,7 @@ snapshots: tinyexec@1.1.2: {} - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -1486,6 +1816,9 @@ snapshots: totalist@3.0.1: {} + tslib@2.8.1: + optional: true + typescript@6.0.3: {} undici-types@7.16.0: {} @@ -1495,7 +1828,7 @@ snapshots: '@oxc-project/types': 0.133.0 '@oxlint/plugins': 1.61.0 '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.0.3)(typescript@6.0.3) - '@voidzero-dev/vite-plus-test': 0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3) + '@voidzero-dev/vite-plus-test': 0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)) oxfmt: 0.52.0(vite-plus@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)) oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@types/node@25.0.3)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.0.3)(typescript@6.0.3))(typescript@6.0.3)) oxlint-tsgolint: 0.23.0 @@ -1540,6 +1873,69 @@ snapshots: - vite - yaml + vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.0.3)(typescript@6.0.3) + '@voidzero-dev/vite-plus-test': 0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3)) + oxfmt: 0.52.0(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@types/node@25.0.3)(typescript@6.0.3)(vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3))) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.23 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.23 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.23 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.23 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + optional: true + + vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68(@types/node@25.0.3): + dependencies: + '@voidzero-dev/vite-task-client': 0.1.1 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e7939e84e..c36f30c2e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,14 @@ catalogMode: prefer overrides: vite: 'catalog:' vitest: 'catalog:' + # The e2e harness symlinks packages/tools' `vite` binary into fixtures to + # exercise the real vite-task-client integration. The global `vite: catalog:` + # override above routes vite to the Vite+ toolchain fork, which does not carry + # the integration — so scope vite-task-tools' `vite` to a real upstream build + # (vitejs/vite main, where the integration merged in vitejs/vite#22453). Bump + # the pinned sha to track main until the integration ships in a tagged vite + # release and this can move to a normal version range. + vite-task-tools>vite: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68 peerDependencyRules: allowAny: - vite