Skip to content

Commit b73e92f

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 9c67598 commit b73e92f

23 files changed

Lines changed: 1896 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 41 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
@@ -150,8 +150,11 @@ vite_shell = { path = "crates/vite_shell" }
150150
vite_str = { path = "crates/vite_str" }
151151
vite_task = { path = "crates/vite_task" }
152152
vite_task_bin = { path = "crates/vite_task_bin" }
153+
vite_task_client = { path = "crates/vite_task_client" }
153154
vite_task_graph = { path = "crates/vite_task_graph" }
155+
vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" }
154156
vite_task_plan = { path = "crates/vite_task_plan" }
157+
vite_task_server = { path = "crates/vite_task_server" }
155158
vite_workspace = { path = "crates/vite_workspace" }
156159
vt100 = "0.16.2"
157160
wax = "0.7.0"
@@ -169,6 +172,10 @@ ignored = [
169172
# These are artifact dependencies. They are not directly `use`d in Rust code.
170173
"fspy_preload_unix",
171174
"fspy_preload_windows",
175+
# Registered in the workspace dependency table so downstream PRs in the
176+
# runner-aware-tools stack can pick it up via `workspace = true` without
177+
# touching this file. No in-tree consumer in this PR.
178+
"vite_task_server",
172179
]
173180

174181
[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, Hash)]
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
rustc-hash = { workspace = true }
12+
vite_path = { workspace = true }
13+
vite_task_ipc_shared = { workspace = true }
14+
wincode = { workspace = true, features = ["derive"] }
15+
16+
[target.'cfg(windows)'.dependencies]
17+
winapi = { workspace = true, features = ["namedpipeapi"] }
18+
19+
[lints]
20+
workspace = true
21+
22+
[lib]
23+
doctest = false
24+
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: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
use std::{
2+
cell::RefCell,
3+
ffi::OsStr,
4+
io::{self, Read, Write},
5+
sync::Arc,
6+
};
7+
8+
use native_str::NativeStr;
9+
use rustc_hash::FxHashMap;
10+
use vite_path::{self, AbsolutePath};
11+
use vite_task_ipc_shared::{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+
/// Fire-and-forget: the call returns once the request is flushed to the
54+
/// kernel pipe buffer; the runner processes it during its drain phase
55+
/// after this process exits. See the `Request` type in the IPC protocol
56+
/// crate for why this is safe.
57+
///
58+
/// # Errors
59+
///
60+
/// Returns an error if the request fails to send, or (for a relative
61+
/// `path`) if the current working directory cannot be read.
62+
pub fn ignore_input(&self, path: &OsStr) -> io::Result<()> {
63+
let ns = resolve_path(path)?;
64+
self.send(&Request::IgnoreInput(&ns))
65+
}
66+
67+
/// `path` can be a file or a directory; for a directory, all files inside
68+
/// it are ignored. Relative paths are resolved against the current working
69+
/// directory before being sent to the runner.
70+
///
71+
/// Fire-and-forget — see [`Self::ignore_input`].
72+
///
73+
/// # Errors
74+
///
75+
/// Returns an error if the request fails to send, or (for a relative
76+
/// `path`) if the current working directory cannot be read.
77+
pub fn ignore_output(&self, path: &OsStr) -> io::Result<()> {
78+
let ns = resolve_path(path)?;
79+
self.send(&Request::IgnoreOutput(&ns))
80+
}
81+
82+
/// Fire-and-forget — see [`Self::ignore_input`].
83+
///
84+
/// # Errors
85+
///
86+
/// Returns an error if the request fails to send.
87+
pub fn disable_cache(&self) -> io::Result<()> {
88+
self.send(&Request::DisableCache)
89+
}
90+
91+
/// Requests an env value from the runner. Returns `None` if the runner reports
92+
/// the env is not available.
93+
///
94+
/// # Errors
95+
///
96+
/// Returns an error if the request or response fails.
97+
pub fn get_env(&self, name: &OsStr, tracked: bool) -> io::Result<Option<Arc<OsStr>>> {
98+
let name = Box::<NativeStr>::from(name);
99+
100+
self.send(&Request::GetEnv { name: &name, tracked })?;
101+
self.recv_with(|bytes| {
102+
let response: GetEnvResponse<'_> = wincode::deserialize_exact(bytes)
103+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
104+
Ok(response
105+
.env_value
106+
.map(|env_value| Arc::<OsStr>::from(env_value.to_cow_os_str().as_ref())))
107+
})
108+
}
109+
110+
/// Requests every env whose name matches `pattern` from the runner. The
111+
/// returned map is keyed by env name with its value.
112+
///
113+
/// Unlike [`Self::get_env`], this always round-trips to the server — the
114+
/// client has no way to know in advance which names the pattern matches.
115+
/// Env names that aren't valid UTF-8 are silently dropped at the server.
116+
///
117+
/// # Errors
118+
///
119+
/// Returns an error if the request or response fails, or if the server
120+
/// rejected the pattern as an invalid glob.
121+
pub fn get_envs(
122+
&self,
123+
pattern: &str,
124+
tracked: bool,
125+
) -> io::Result<FxHashMap<Arc<OsStr>, Arc<OsStr>>> {
126+
self.send(&Request::GetEnvs { pattern, tracked })?;
127+
self.recv_with(|bytes| {
128+
let response: GetEnvsResponse<'_> = wincode::deserialize_exact(bytes)
129+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
130+
Ok(response
131+
.entries
132+
.iter()
133+
.map(|(name, value)| {
134+
(
135+
Arc::<OsStr>::from(name.to_cow_os_str().as_ref()),
136+
Arc::<OsStr>::from(value.to_cow_os_str().as_ref()),
137+
)
138+
})
139+
.collect())
140+
})
141+
}
142+
143+
fn send(&self, request: &Request<'_>) -> io::Result<()> {
144+
let bytes = wincode::serialize(request)
145+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
146+
let len = u32::try_from(bytes.len())
147+
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "request too large"))?;
148+
let mut stream = self.stream.borrow_mut();
149+
stream.write_all(&len.to_le_bytes())?;
150+
stream.write_all(&bytes)?;
151+
stream.flush()?;
152+
Ok(())
153+
}
154+
155+
fn recv_with<T>(&self, extract: impl FnOnce(&[u8]) -> io::Result<T>) -> io::Result<T> {
156+
let mut stream = self.stream.borrow_mut();
157+
let mut scratch = self.scratch.borrow_mut();
158+
let mut len_bytes = [0u8; 4];
159+
stream.read_exact(&mut len_bytes)?;
160+
let len = u32::from_le_bytes(len_bytes) as usize;
161+
scratch.clear();
162+
scratch.resize(len, 0);
163+
stream.read_exact(&mut scratch)?;
164+
extract(&scratch)
165+
}
166+
}
167+
168+
#[cfg(unix)]
169+
fn connect(name: &OsStr) -> io::Result<Stream> {
170+
std::os::unix::net::UnixStream::connect(name)
171+
}
172+
173+
/// Open a Windows named pipe as a client.
174+
///
175+
/// `OpenOptions::open` on a named-pipe path fails with `ERROR_PIPE_BUSY` when
176+
/// the server's only pending instance has just been claimed by another client
177+
/// — the brief window between the server accepting one connection and creating
178+
/// the next instance. On `ERROR_PIPE_BUSY` we hand off to the kernel via
179+
/// `WaitNamedPipeW`, which blocks until an instance becomes available (or
180+
/// fails if the named pipe is gone). No polling and no arbitrary timeouts.
181+
///
182+
/// This matches what the `interprocess` crate does internally.
183+
#[cfg(windows)]
184+
fn connect(name: &OsStr) -> io::Result<Stream> {
185+
use std::{fs::OpenOptions, os::windows::ffi::OsStrExt};
186+
187+
use winapi::um::namedpipeapi::WaitNamedPipeW;
188+
189+
// ERROR_PIPE_BUSY — see WinError.h. `std::io::Error` does not expose a
190+
// typed constant for this, so the raw OS code is the cleanest test.
191+
const ERROR_PIPE_BUSY: i32 = 231;
192+
// NMPWAIT_WAIT_FOREVER — see WinBase.h. winapi 0.3 doesn't define the
193+
// NMPWAIT_* constants yet (only the comment placeholder).
194+
const NMPWAIT_WAIT_FOREVER: u32 = 0xFFFF_FFFF;
195+
196+
// `WaitNamedPipeW` needs a NUL-terminated UTF-16 path.
197+
let mut wide: Vec<u16> = name.encode_wide().collect();
198+
wide.push(0);
199+
200+
loop {
201+
match OpenOptions::new().read(true).write(true).open(name) {
202+
Ok(file) => return Ok(file),
203+
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
204+
// SAFETY: `wide` is NUL-terminated; pointer stays valid for
205+
// the call's duration. `NMPWAIT_WAIT_FOREVER` makes this a
206+
// bounded kernel wait (server's pipe wait-timeout is the
207+
// upper bound on each retry; default ~50ms, then we loop).
208+
let ok = unsafe { WaitNamedPipeW(wide.as_ptr(), NMPWAIT_WAIT_FOREVER) };
209+
if ok == 0 {
210+
return Err(io::Error::last_os_error());
211+
}
212+
// Loop and re-open — another client may have raced us to the
213+
// newly-available instance.
214+
}
215+
Err(err) => return Err(err),
216+
}
217+
}
218+
}
219+
220+
#[expect(
221+
clippy::disallowed_types,
222+
reason = "std::path::PathBuf is needed to round-trip through std::fs::canonicalize on Windows below"
223+
)]
224+
fn resolve_path(path: &OsStr) -> io::Result<Box<NativeStr>> {
225+
let absolute: std::path::PathBuf = if let Some(abs) = AbsolutePath::new(path) {
226+
abs.as_path().to_path_buf()
227+
} else {
228+
let mut buf = vite_path::current_dir()?;
229+
buf.push(path);
230+
buf.as_path().to_path_buf()
231+
};
232+
233+
// On Windows, canonicalize so the path uses the exact on-disk casing
234+
// and resolves substitute drives / junctions the same way `fspy`'s
235+
// `GetFinalPathNameByHandleW`-reported paths do. Without this, an
236+
// `ignoreInput("cache_like")` whose `current_dir()` prefix differs in
237+
// case or symlink shape from the fspy-reported reads won't filter
238+
// them out, and the runner sees a read/write overlap. Strip the
239+
// `\\?\` namespace prefix because `fspy_shared::NativePath::
240+
// strip_path_prefix` does the same on the runner side; if the
241+
// canonical form starts with `\\?\UNC\`, fall back to the
242+
// non-canonical form so we don't accidentally rewrite a UNC path
243+
// (where dropping `\\?\` would change meaning).
244+
#[cfg(windows)]
245+
let absolute = std::fs::canonicalize(&absolute).map_or(absolute, |canonical| {
246+
use std::{
247+
ffi::OsString,
248+
os::windows::ffi::{OsStrExt, OsStringExt},
249+
};
250+
let wide: Vec<u16> = canonical.as_os_str().encode_wide().collect();
251+
let unc_prefix: Vec<u16> = r"\\?\UNC\".encode_utf16().collect();
252+
let nt_prefix: Vec<u16> = r"\\?\".encode_utf16().collect();
253+
if wide.starts_with(&unc_prefix) {
254+
// UNC path — keep canonical form (still has \\?\UNC\ for fspy parity).
255+
canonical
256+
} else if let Some(rest) = wide.strip_prefix(nt_prefix.as_slice()) {
257+
std::path::PathBuf::from(OsString::from_wide(rest))
258+
} else {
259+
canonical
260+
}
261+
});
262+
263+
Ok(Box::<NativeStr>::from(absolute.as_os_str()))
264+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.non-vite.clippy.toml

0 commit comments

Comments
 (0)