Skip to content

Commit 954cd3b

Browse files
authored
fix(workspace): avoid holding workspace file handles across session (#335)
## Summary - Refactor `FileWithPath` to pre-read file contents into memory and close the OS handle immediately, rather than keeping a live `std::fs::File` for the entire lifetime of `WorkspaceRoot`. - Update `load_package_graph` to deserialize via `serde_*::from_slice(content())` instead of `from_reader(file())`; drop the now-unnecessary seek-back step in `find_workspace_root`. - Add regression tests: a cross-platform rename-over-target test modeling pnpm's atomic update flow while `WorkspaceRoot` is alive, a Linux-only `/proc/self/fd` check, and basic correctness tests for the new API. ## Why On Windows, `vp run dev` keeps a `Session` (and its `WorkspaceRoot`) alive for the full duration of the dev server. Holding an open handle to `pnpm-workspace.yaml` in that `WorkspaceRoot` could prevent pnpm in a second terminal from atomically replacing the file (write-temp-then-rename), surfacing as `EPERM: operation not permitted` when pnpm tried to add a catalog entry — see voidzero-dev/vite-plus#1357. Even where current Rust's default `FILE_SHARE_DELETE` makes the rename succeed, long-lived handles to config files are fragile across OS/AV/filesystem edge cases. Reading content once and closing the handle is strictly simpler. ## Test plan - [x] `cargo test -p vite_workspace` — 78 passing (including 4 new tests) - [x] `cargo test -p vite_task -p vite_task_plan -p vite_task_graph` — all pass - [x] `cargo clippy -p vite_workspace --all-targets -- -D warnings` — clean - [x] Verified end-to-end against vite-plus via local `[patch]`: all vite-plus own crate tests pass. - [x] Windows CI rerun to exercise the new regression test on the target platform. ## Follow-up Once merged, bump the `vite-task` git rev in `voidzero-dev/vite-plus` `Cargo.toml` (entries at lines 92, 197, 203–206) to pick up the fix. Fixes voidzero-dev/vite-plus#1357
1 parent 26baabe commit 954cd3b

File tree

2 files changed

+108
-37
lines changed

2 files changed

+108
-37
lines changed

crates/vite_workspace/src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,24 +248,24 @@ pub fn load_package_graph(
248248
let mut graph_builder = PackageGraphBuilder::default();
249249
let workspaces = match &workspace_root.workspace_file {
250250
WorkspaceFile::PnpmWorkspaceYaml(file_with_path) => {
251-
let workspace: PnpmWorkspace =
252-
serde_yml::from_reader(file_with_path.file()).map_err(|e| Error::SerdeYml {
251+
let workspace: PnpmWorkspace = serde_yml::from_slice(file_with_path.content())
252+
.map_err(|e| Error::SerdeYml {
253253
file_path: Arc::clone(file_with_path.path()),
254254
serde_yml_error: e,
255255
})?;
256256
workspace.packages
257257
}
258258
WorkspaceFile::NpmWorkspaceJson(file_with_path) => {
259-
let workspace: NpmWorkspace =
260-
serde_json::from_reader(file_with_path.file()).map_err(|e| Error::SerdeJson {
259+
let workspace: NpmWorkspace = serde_json::from_slice(file_with_path.content())
260+
.map_err(|e| Error::SerdeJson {
261261
file_path: Arc::clone(file_with_path.path()),
262262
serde_json_error: e,
263263
})?;
264264
workspace.workspaces.into_packages()
265265
}
266266
WorkspaceFile::NonWorkspacePackage(file_with_path) => {
267267
// For non-workspace packages, add the package.json to the graph as a root package
268-
let package_json: PackageJson = serde_json::from_reader(file_with_path.file())
268+
let package_json: PackageJson = serde_json::from_slice(file_with_path.content())
269269
.map_err(|e| Error::SerdeJson {
270270
file_path: Arc::clone(file_with_path.path()),
271271
serde_json_error: e,

crates/vite_workspace/src/package_manager.rs

Lines changed: 103 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,49 @@
1-
use std::{
2-
fs::File,
3-
io::{BufReader, Seek, SeekFrom},
4-
sync::Arc,
5-
};
1+
use std::{fs, sync::Arc};
62

73
use vite_path::{AbsolutePath, RelativePathBuf};
84

95
use crate::Error;
106

11-
/// A file handle bundled with its absolute path for error context.
7+
/// The contents of a file bundled with its absolute path for error context.
8+
///
9+
/// The file is read to memory on construction and its handle closed
10+
/// immediately — the struct itself never holds a live OS file handle. This
11+
/// keeps long-lived `WorkspaceRoot`s (held across an entire `vp run` session)
12+
/// from pinning files like `pnpm-workspace.yaml`, which on Windows could
13+
/// otherwise block pnpm's atomic write-and-rename and fail with EPERM
14+
/// (<https://github.com/voidzero-dev/vite-plus/issues/1357>).
1215
#[derive(Debug)]
1316
pub struct FileWithPath {
14-
file: File,
17+
content: Vec<u8>,
1518
path: Arc<AbsolutePath>,
1619
}
1720

1821
impl FileWithPath {
19-
/// Open a file at the given path.
22+
/// Open a file at the given path and read its contents into memory.
2023
///
2124
/// # Errors
22-
/// Returns an error if the file cannot be opened.
25+
/// Returns an error if the file cannot be read.
2326
pub fn open(path: Arc<AbsolutePath>) -> Result<Self, Error> {
24-
let file = File::open(&*path)?;
25-
Ok(Self { file, path })
27+
let content = fs::read(&*path)?;
28+
Ok(Self { content, path })
2629
}
2730

28-
/// Try to open a file, returning None if it doesn't exist.
31+
/// Try to read a file, returning None if it doesn't exist.
2932
///
3033
/// # Errors
31-
/// Returns an error if the file exists but cannot be opened.
34+
/// Returns an error if the file exists but cannot be read.
3235
pub fn open_if_exists(path: Arc<AbsolutePath>) -> Result<Option<Self>, Error> {
33-
match File::open(&*path) {
34-
Ok(file) => Ok(Some(Self { file, path })),
36+
match fs::read(&*path) {
37+
Ok(content) => Ok(Some(Self { content, path })),
3538
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
3639
Err(e) => Err(e.into()),
3740
}
3841
}
3942

40-
/// Get a reference to the file handle.
43+
/// Get the file contents as a byte slice.
4144
#[must_use]
42-
pub const fn file(&self) -> &File {
43-
&self.file
44-
}
45-
46-
/// Get a mutable reference to the file handle.
47-
pub const fn file_mut(&mut self) -> &mut File {
48-
&mut self.file
45+
pub fn content(&self) -> &[u8] {
46+
&self.content
4947
}
5048

5149
/// Get the file path.
@@ -156,17 +154,13 @@ pub fn find_workspace_root(
156154

157155
// Check for package.json with workspaces field for npm/yarn workspace
158156
let package_json_path: Arc<AbsolutePath> = cwd.join("package.json").into();
159-
if let Some(mut file_with_path) = FileWithPath::open_if_exists(package_json_path)? {
160-
let package_json: serde_json::Value =
161-
serde_json::from_reader(BufReader::new(file_with_path.file())).map_err(|e| {
162-
Error::SerdeJson {
163-
file_path: Arc::clone(file_with_path.path()),
164-
serde_json_error: e,
165-
}
157+
if let Some(file_with_path) = FileWithPath::open_if_exists(package_json_path)? {
158+
let package_json: serde_json::Value = serde_json::from_slice(file_with_path.content())
159+
.map_err(|e| Error::SerdeJson {
160+
file_path: Arc::clone(file_with_path.path()),
161+
serde_json_error: e,
166162
})?;
167163
if package_json.get("workspaces").is_some() {
168-
// Reset the file cursor since we consumed it reading
169-
file_with_path.file_mut().seek(SeekFrom::Start(0))?;
170164
let relative_cwd =
171165
original_cwd.strip_prefix(cwd)?.expect("cwd must be within the workspace");
172166
return Ok((
@@ -195,3 +189,80 @@ pub fn find_workspace_root(
195189
}
196190
}
197191
}
192+
193+
#[cfg(test)]
194+
mod tests {
195+
use tempfile::TempDir;
196+
197+
use super::*;
198+
199+
/// Regression test for <https://github.com/voidzero-dev/vite-plus/issues/1357>:
200+
/// on Windows, an open handle to `pnpm-workspace.yaml` without
201+
/// `FILE_SHARE_DELETE` blocks pnpm's atomic write-tmp-then-rename.
202+
#[test]
203+
fn find_workspace_root_does_not_lock_pnpm_workspace_yaml() {
204+
let temp_dir = TempDir::new().unwrap();
205+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
206+
let ws_yaml = temp_dir_path.join("pnpm-workspace.yaml");
207+
let ws_yaml_tmp = temp_dir_path.join("pnpm-workspace.yaml.tmp");
208+
209+
fs::write(&ws_yaml, b"packages:\n - apps/*\n").unwrap();
210+
211+
let (workspace_root, _) = find_workspace_root(temp_dir_path).unwrap();
212+
213+
fs::write(&ws_yaml_tmp, b"packages:\n - apps/*\n - packages/*\n").unwrap();
214+
fs::rename(&ws_yaml_tmp, &ws_yaml)
215+
.expect("rename over pnpm-workspace.yaml must succeed while WorkspaceRoot is alive");
216+
217+
drop(workspace_root);
218+
}
219+
220+
/// Linux-only: `/proc/self/fd` lets us verify no descriptor remains
221+
/// pointing at `pnpm-workspace.yaml` regardless of Rust's default
222+
/// share mode on the platform.
223+
#[cfg(target_os = "linux")]
224+
#[test]
225+
fn find_workspace_root_releases_pnpm_workspace_yaml_fd_on_linux() {
226+
let temp_dir = TempDir::new().unwrap();
227+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
228+
let ws_yaml = temp_dir_path.join("pnpm-workspace.yaml");
229+
fs::write(&ws_yaml, b"packages:\n - apps/*\n").unwrap();
230+
231+
let (workspace_root, _) = find_workspace_root(temp_dir_path).unwrap();
232+
233+
let ws_yaml_canonical = fs::canonicalize(&ws_yaml).unwrap();
234+
let mut open_to_target = 0;
235+
for entry in fs::read_dir("/proc/self/fd").unwrap().flatten() {
236+
if let Ok(link) = fs::read_link(entry.path())
237+
&& link == ws_yaml_canonical
238+
{
239+
open_to_target += 1;
240+
}
241+
}
242+
assert_eq!(
243+
open_to_target, 0,
244+
"expected no open file descriptor for pnpm-workspace.yaml, got {open_to_target}",
245+
);
246+
drop(workspace_root);
247+
}
248+
249+
#[test]
250+
fn file_with_path_content_matches_file_on_disk() {
251+
let temp_dir = TempDir::new().unwrap();
252+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
253+
let path: Arc<AbsolutePath> = temp_dir_path.join("pnpm-workspace.yaml").into();
254+
fs::write(&*path, b"packages:\n - apps/*\n").unwrap();
255+
256+
let file_with_path = FileWithPath::open(Arc::clone(&path)).unwrap();
257+
assert_eq!(file_with_path.content(), b"packages:\n - apps/*\n");
258+
assert_eq!(&**file_with_path.path(), &*path);
259+
}
260+
261+
#[test]
262+
fn file_with_path_open_if_exists_returns_none_when_missing() {
263+
let temp_dir = TempDir::new().unwrap();
264+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
265+
let path: Arc<AbsolutePath> = temp_dir_path.join("missing.yaml").into();
266+
assert!(FileWithPath::open_if_exists(path).unwrap().is_none());
267+
}
268+
}

0 commit comments

Comments
 (0)