Skip to content

Commit fceab96

Browse files
wan9chiclaude
andcommitted
feat(ipc): protocol + Rust transport
Add three new crates that together define the runner-tool IPC: - `vite_task_ipc_shared` — message types + wire format shared by both ends. Spawned tools tell the runner what they read, wrote, or cared about; the runner uses that to decide what to fingerprint in the cache. - `vite_task_server` — async server hosted in the runner. One server instance per task execution. - `vite_task_client` — synchronous blocking client used by spawned tools. The sync API is deliberate: most tools are JS, called one-method-at-a-time, and don't want a runtime imposed on them. The two sides are wired together end-to-end in `vite_task_server/tests/integration.rs`, so this PR is independently reviewable as "does the wire format and transport work correctly?" without depending on runner internals. Design notes: `docs/runner-task-ipc/`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5833b37 commit fceab96

23 files changed

Lines changed: 1871 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,11 @@ vite_shell = { path = "crates/vite_shell" }
149149
vite_str = { path = "crates/vite_str" }
150150
vite_task = { path = "crates/vite_task" }
151151
vite_task_bin = { path = "crates/vite_task_bin" }
152+
vite_task_client = { path = "crates/vite_task_client" }
152153
vite_task_graph = { path = "crates/vite_task_graph" }
154+
vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" }
153155
vite_task_plan = { path = "crates/vite_task_plan" }
156+
vite_task_server = { path = "crates/vite_task_server" }
154157
vite_workspace = { path = "crates/vite_workspace" }
155158
vt100 = "0.16.2"
156159
wax = "0.7.0"
@@ -168,6 +171,10 @@ ignored = [
168171
# These are artifact dependencies. They are not directly `use`d in Rust code.
169172
"fspy_preload_unix",
170173
"fspy_preload_windows",
174+
# Registered in the workspace dependency table so downstream PRs in the
175+
# runner-aware-tools stack can pick it up via `workspace = true` without
176+
# touching this file. No in-tree consumer in this PR.
177+
"vite_task_server",
171178
]
172179

173180
[profile.dev]

crates/native_str/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use wincode::{
3737
/// **Not portable across platforms.** The binary representation is platform-specific.
3838
/// Deserializing a `NativeStr` serialized on a different platform leads to unspecified
3939
/// behavior (garbage data), but is not unsafe. Designed for same-platform IPC only.
40-
#[derive(TransparentWrapper, PartialEq, Eq)]
40+
#[derive(TransparentWrapper, PartialEq, Eq, PartialOrd, Ord)]
4141
#[repr(transparent)]
4242
pub struct NativeStr {
4343
// On unix, this is the raw bytes of the OsStr.

crates/vite_task_client/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "vite_task_client"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
license.workspace = true
6+
publish = false
7+
rust-version.workspace = true
8+
9+
[dependencies]
10+
native_str = { workspace = true }
11+
vite_path = { workspace = true }
12+
vite_task_ipc_shared = { workspace = true }
13+
wincode = { workspace = true, features = ["derive"] }
14+
15+
[lints]
16+
workspace = true
17+
18+
[lib]
19+
doctest = false
20+
test = false

crates/vite_task_client/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# vite_task_client
2+
3+
IPC client that connects from tool processes to the task runner to report inputs/outputs, request env values, and disable caching.

crates/vite_task_client/src/lib.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
use std::{
2+
cell::RefCell,
3+
collections::BTreeMap,
4+
ffi::OsStr,
5+
io::{self, Read, Write},
6+
sync::Arc,
7+
};
8+
9+
use native_str::NativeStr;
10+
use vite_path::{self, AbsolutePath};
11+
use vite_task_ipc_shared::{Ack, GetEnvResponse, GetEnvsResponse, IPC_ENV_NAME, Request};
12+
13+
#[cfg(unix)]
14+
type Stream = std::os::unix::net::UnixStream;
15+
#[cfg(windows)]
16+
type Stream = std::fs::File;
17+
18+
pub struct Client {
19+
stream: RefCell<Stream>,
20+
scratch: RefCell<Vec<u8>>,
21+
}
22+
23+
impl Client {
24+
/// Scans `envs` for the runner's IPC connection info and connects if
25+
/// present. Typical callers pass `std::env::vars_os()`.
26+
///
27+
/// Returns `Ok(None)` if the IPC env is absent (running outside the runner).
28+
/// `Err(..)` if the env is set but connecting fails.
29+
///
30+
/// # Errors
31+
///
32+
/// Returns an error if the env var is set but the server cannot be reached.
33+
pub fn from_envs(
34+
envs: impl Iterator<Item = (impl AsRef<OsStr>, impl AsRef<OsStr>)>,
35+
) -> io::Result<Option<Self>> {
36+
for (name, value) in envs {
37+
if name.as_ref() == IPC_ENV_NAME {
38+
let stream = connect(value.as_ref())?;
39+
return Ok(Some(Self::from_stream(stream)));
40+
}
41+
}
42+
Ok(None)
43+
}
44+
45+
const fn from_stream(stream: Stream) -> Self {
46+
Self { stream: RefCell::new(stream), scratch: RefCell::new(Vec::new()) }
47+
}
48+
49+
/// `path` can be a file or a directory; for a directory, all files inside
50+
/// it are ignored. Relative paths are resolved against the current working
51+
/// directory before being sent to the runner.
52+
///
53+
/// # Errors
54+
///
55+
/// Returns an error if the request fails to send, or (for a relative
56+
/// `path`) if the current working directory cannot be read.
57+
pub fn ignore_input(&self, path: &OsStr) -> io::Result<()> {
58+
let ns = resolve_path(path)?;
59+
self.send(&Request::IgnoreInput(&ns))?;
60+
self.recv_ack()
61+
}
62+
63+
/// `path` can be a file or a directory; for a directory, all files inside
64+
/// it are ignored. Relative paths are resolved against the current working
65+
/// directory before being sent to the runner.
66+
///
67+
/// # Errors
68+
///
69+
/// Returns an error if the request fails to send, or (for a relative
70+
/// `path`) if the current working directory cannot be read.
71+
pub fn ignore_output(&self, path: &OsStr) -> io::Result<()> {
72+
let ns = resolve_path(path)?;
73+
self.send(&Request::IgnoreOutput(&ns))?;
74+
self.recv_ack()
75+
}
76+
77+
/// # Errors
78+
///
79+
/// Returns an error if the request fails to send.
80+
pub fn disable_cache(&self) -> io::Result<()> {
81+
self.send(&Request::DisableCache)?;
82+
self.recv_ack()
83+
}
84+
85+
fn recv_ack(&self) -> io::Result<()> {
86+
self.recv_with(|bytes| {
87+
let _: Ack = wincode::deserialize_exact(bytes)
88+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
89+
Ok(())
90+
})
91+
}
92+
93+
/// Requests an env value from the runner. Returns `None` if the runner reports
94+
/// the env is not available.
95+
///
96+
/// # Errors
97+
///
98+
/// Returns an error if the request or response fails.
99+
pub fn get_env(&self, name: &OsStr, tracked: bool) -> io::Result<Option<Arc<OsStr>>> {
100+
let name = Box::<NativeStr>::from(name);
101+
102+
self.send(&Request::GetEnv { name: &name, tracked })?;
103+
self.recv_with(|bytes| {
104+
let response: GetEnvResponse<'_> = wincode::deserialize_exact(bytes)
105+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
106+
Ok(response
107+
.env_value
108+
.map(|env_value| Arc::<OsStr>::from(env_value.to_cow_os_str().as_ref())))
109+
})
110+
}
111+
112+
/// Requests every env whose name matches `pattern` from the runner. The
113+
/// returned map is keyed by env name (sorted) with its value.
114+
///
115+
/// Unlike [`Self::get_env`], this always round-trips to the server — the
116+
/// client has no way to know in advance which names the pattern matches.
117+
/// Env names that aren't valid UTF-8 are silently dropped at the server.
118+
///
119+
/// # Errors
120+
///
121+
/// Returns an error if the request or response fails, or if the server
122+
/// rejected the pattern as an invalid glob.
123+
pub fn get_envs(
124+
&self,
125+
pattern: &str,
126+
tracked: bool,
127+
) -> io::Result<BTreeMap<Arc<OsStr>, Arc<OsStr>>> {
128+
self.send(&Request::GetEnvs { pattern, tracked })?;
129+
self.recv_with(|bytes| {
130+
let response: GetEnvsResponse<'_> = wincode::deserialize_exact(bytes)
131+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
132+
Ok(response
133+
.entries
134+
.iter()
135+
.map(|(name, value)| {
136+
(
137+
Arc::<OsStr>::from(name.to_cow_os_str().as_ref()),
138+
Arc::<OsStr>::from(value.to_cow_os_str().as_ref()),
139+
)
140+
})
141+
.collect())
142+
})
143+
}
144+
145+
fn send(&self, request: &Request<'_>) -> io::Result<()> {
146+
let bytes = wincode::serialize(request)
147+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
148+
let len = u32::try_from(bytes.len())
149+
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "request too large"))?;
150+
let mut stream = self.stream.borrow_mut();
151+
stream.write_all(&len.to_le_bytes())?;
152+
stream.write_all(&bytes)?;
153+
stream.flush()?;
154+
Ok(())
155+
}
156+
157+
fn recv_with<T>(&self, extract: impl FnOnce(&[u8]) -> io::Result<T>) -> io::Result<T> {
158+
let mut stream = self.stream.borrow_mut();
159+
let mut scratch = self.scratch.borrow_mut();
160+
let mut len_bytes = [0u8; 4];
161+
stream.read_exact(&mut len_bytes)?;
162+
let len = u32::from_le_bytes(len_bytes) as usize;
163+
scratch.clear();
164+
scratch.resize(len, 0);
165+
stream.read_exact(&mut scratch)?;
166+
extract(&scratch)
167+
}
168+
}
169+
170+
#[cfg(unix)]
171+
fn connect(name: &OsStr) -> io::Result<Stream> {
172+
std::os::unix::net::UnixStream::connect(name)
173+
}
174+
175+
/// Open a Windows named pipe as a client.
176+
///
177+
/// `OpenOptions::open` on a named-pipe path fails with `ERROR_PIPE_BUSY` when
178+
/// the server's only pending instance has just been claimed by another client
179+
/// — the brief window between the server accepting one connection and creating
180+
/// the next instance. The retry loop bridges that window; the deadline keeps
181+
/// the wait bounded if the server has actually gone away.
182+
#[cfg(windows)]
183+
fn connect(name: &OsStr) -> io::Result<Stream> {
184+
use std::{
185+
fs::OpenOptions,
186+
thread,
187+
time::{Duration, Instant},
188+
};
189+
190+
// ERROR_PIPE_BUSY — see WinError.h. `std::io::Error` does not expose a
191+
// typed constant for this, so the raw OS code is the cleanest test.
192+
const ERROR_PIPE_BUSY: i32 = 231;
193+
194+
let deadline = Instant::now() + Duration::from_secs(5);
195+
loop {
196+
match OpenOptions::new().read(true).write(true).open(name) {
197+
Ok(file) => return Ok(file),
198+
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
199+
if Instant::now() >= deadline {
200+
return Err(err);
201+
}
202+
thread::sleep(Duration::from_millis(1));
203+
}
204+
Err(err) => return Err(err),
205+
}
206+
}
207+
}
208+
209+
#[expect(
210+
clippy::disallowed_types,
211+
reason = "std::path::PathBuf is needed to round-trip through std::fs::canonicalize on Windows below"
212+
)]
213+
fn resolve_path(path: &OsStr) -> io::Result<Box<NativeStr>> {
214+
let absolute: std::path::PathBuf = if let Some(abs) = AbsolutePath::new(path) {
215+
abs.as_path().to_path_buf()
216+
} else {
217+
let mut buf = vite_path::current_dir()?;
218+
buf.push(path);
219+
buf.as_path().to_path_buf()
220+
};
221+
222+
// On Windows, canonicalize so the path uses the exact on-disk casing
223+
// and resolves substitute drives / junctions the same way `fspy`'s
224+
// `GetFinalPathNameByHandleW`-reported paths do. Without this, an
225+
// `ignoreInput("cache_like")` whose `current_dir()` prefix differs in
226+
// case or symlink shape from the fspy-reported reads won't filter
227+
// them out, and the runner sees a read/write overlap. Strip the
228+
// `\\?\` namespace prefix because `fspy_shared::NativePath::
229+
// strip_path_prefix` does the same on the runner side; if the
230+
// canonical form starts with `\\?\UNC\`, fall back to the
231+
// non-canonical form so we don't accidentally rewrite a UNC path
232+
// (where dropping `\\?\` would change meaning).
233+
#[cfg(windows)]
234+
let absolute = match std::fs::canonicalize(&absolute) {
235+
Ok(canonical) => {
236+
use std::{
237+
ffi::OsString,
238+
os::windows::ffi::{OsStrExt, OsStringExt},
239+
};
240+
let wide: Vec<u16> = canonical.as_os_str().encode_wide().collect();
241+
let unc_prefix: Vec<u16> = r"\\?\UNC\".encode_utf16().collect();
242+
let nt_prefix: Vec<u16> = r"\\?\".encode_utf16().collect();
243+
if wide.starts_with(&unc_prefix) {
244+
// UNC path — keep canonical form (still has \\?\UNC\ for fspy parity).
245+
canonical
246+
} else if let Some(rest) = wide.strip_prefix(nt_prefix.as_slice()) {
247+
std::path::PathBuf::from(OsString::from_wide(rest))
248+
} else {
249+
canonical
250+
}
251+
}
252+
Err(_) => absolute,
253+
};
254+
255+
Ok(Box::<NativeStr>::from(absolute.as_os_str()))
256+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.non-vite.clippy.toml
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "vite_task_ipc_shared"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
license.workspace = true
6+
publish = false
7+
rust-version.workspace = true
8+
9+
[dependencies]
10+
native_str = { workspace = true }
11+
wincode = { workspace = true, features = ["derive"] }
12+
13+
[lints]
14+
workspace = true
15+
16+
[lib]
17+
doctest = false
18+
test = false
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# vite_task_ipc_shared
2+
3+
Shared IPC message types for communication between the task runner and tools.

0 commit comments

Comments
 (0)