Skip to content

Commit 11f634d

Browse files
wan9chicodex
andcommitted
fix(fspy): avoid static spawnSync failures
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent c7d14ad commit 11f634d

9 files changed

Lines changed: 101 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** `vp run` no longer fails Node `spawnSync` calls to statically linked Linux tools such as `tsgolint`; when fspy cannot fully trace that child process, the task now runs successfully and skips writing an unsafe cache entry ([#499](https://github.com/voidzero-dev/vite-task/issues/499)).
34
- **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)).
45
- **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)).
56
- **Added** Support for specifying tasks from dependency packages in `dependsOn`, such as `dependsOn: [{ "task": "build", "from": "dependencies" }]` ([#479](https://github.com/voidzero-dev/vite-task/pull/479)).

crates/fspy/tests/node_fs.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,43 @@ use std::{
44
env::{current_dir, vars_os},
55
ffi::OsStr,
66
};
7+
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
8+
use std::{
9+
fs::{self, Permissions},
10+
os::unix::fs::PermissionsExt as _,
11+
path::{Path, PathBuf},
12+
sync::LazyLock,
13+
};
714

815
use fspy::{AccessMode, PathAccessIterable};
16+
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
17+
use fspy_shared_unix::is_dynamically_linked_to_libc;
918
use test_log::test;
1019
use test_utils::assert_contains;
1120

21+
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
22+
const TEST_BIN_CONTENT: &[u8] = include_bytes!(env!("CARGO_BIN_FILE_FSPY_TEST_BIN"));
23+
24+
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
25+
fn test_bin_path() -> &'static Path {
26+
static TEST_BIN_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
27+
assert_eq!(
28+
is_dynamically_linked_to_libc(TEST_BIN_CONTENT),
29+
Ok(false),
30+
"Test binary is not a static executable"
31+
);
32+
33+
let tmp_dir = env!("CARGO_TARGET_TMPDIR");
34+
let test_bin_path = PathBuf::from(tmp_dir).join("fspy-node-spawn-test-bin");
35+
fs::write(&test_bin_path, TEST_BIN_CONTENT).expect("failed to write test binary");
36+
fs::set_permissions(&test_bin_path, Permissions::from_mode(0o755))
37+
.expect("failed to set permissions on test binary");
38+
39+
test_bin_path
40+
});
41+
TEST_BIN_PATH.as_path()
42+
}
43+
1244
async fn track_node_script(script: &str, args: &[&OsStr]) -> anyhow::Result<PathAccessIterable> {
1345
let mut command = fspy::Command::new("node");
1446
command
@@ -110,3 +142,29 @@ async fn subprocess() -> anyhow::Result<()> {
110142
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::READ);
111143
Ok(())
112144
}
145+
146+
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
147+
#[test(tokio::test)]
148+
#[ignore = "requires node"]
149+
async fn spawn_sync_static_executable() -> anyhow::Result<()> {
150+
let accesses = track_node_script(
151+
r"
152+
const { spawnSync } = require('node:child_process');
153+
const result = spawnSync(process.argv[1], ['stat', '/hello'], { stdio: 'ignore' });
154+
if (result.error) {
155+
console.error(result.error.stack || result.error);
156+
process.exit(1);
157+
}
158+
process.exit(result.status ?? 1);
159+
",
160+
&[test_bin_path().as_os_str()],
161+
)
162+
.await?;
163+
164+
assert!(
165+
accesses.iter().any(|access| access.mode.contains(AccessMode::UNTRACKED_EXEC)),
166+
"expected fspy to report incomplete tracking for the static spawnSync child"
167+
);
168+
169+
Ok(())
170+
}

crates/fspy_preload_unix/src/client/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ impl Client {
8888
Ok(())
8989
}
9090

91+
pub fn record_untracked_exec(&self, path: &Path) {
92+
let _ = self.send(fspy_shared::ipc::AccessMode::UNTRACKED_EXEC, path);
93+
}
94+
9195
pub unsafe fn handle_exec<R>(
9296
&self,
9397
config: ExecResolveConfig,

crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::thread;
1+
use std::{ffi::CStr, os::unix::ffi::OsStrExt, path::Path};
22

33
use fspy_shared_unix::exec::ExecResolveConfig;
44
use libc::{c_char, c_int};
@@ -31,14 +31,6 @@ unsafe fn handle_posix_spawn(
3131
argv: *const *mut c_char,
3232
envp: *const *mut c_char,
3333
) -> c_int {
34-
struct AssertSend<T>(T);
35-
#[expect(
36-
clippy::non_send_fields_in_send_ty,
37-
reason = "the closure captures raw pointers that are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe"
38-
)]
39-
// SAFETY: the raw pointers captured inside T are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe
40-
unsafe impl<T> Send for AssertSend<T> {}
41-
4234
let client = global_client()
4335
.expect("posix_spawn(p) unexpectedly called before client initialized in ctor");
4436

@@ -58,21 +50,21 @@ unsafe fn handle_posix_spawn(
5850
raw_command.envp.cast(),
5951
)
6052
};
61-
if let Some(pre_exec) = pre_exec {
62-
thread::scope(move |s| {
63-
let call_original = AssertSend(call_original);
64-
s.spawn(move || {
65-
let call_original = call_original;
66-
pre_exec.run()?;
67-
68-
nix::Result::Ok((call_original.0)())
69-
})
70-
.join()
71-
.unwrap()
72-
})
73-
} else {
74-
Ok(call_original())
53+
if pre_exec.is_some() {
54+
// Static Linux executables cannot be instrumented through
55+
// LD_PRELOAD. Installing a seccomp-unotify listener here
56+
// can make libc's posix_spawn fail before the child exists
57+
// (reported by Node as spawnSync EINVAL). Let the spawn
58+
// proceed, but tell the parent the trace is incomplete so
59+
// task caching is skipped.
60+
// SAFETY: raw_command.prog is a valid C string produced
61+
// from the resolved Exec just above.
62+
let program = CStr::from_ptr(raw_command.prog);
63+
client.record_untracked_exec(Path::new(std::ffi::OsStr::from_bytes(
64+
program.to_bytes(),
65+
)));
7566
}
67+
Ok(call_original())
7668
},
7769
)
7870
};

crates/fspy_shared/src/ipc/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ bitflags! {
1616
const READ = 1;
1717
const WRITE = 1 << 1;
1818
const READ_DIR = 1 << 2;
19+
const UNTRACKED_EXEC = 1 << 3;
1920
}
2021
}
2122

crates/vite_task/src/session/event.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,8 @@ pub enum CacheNotUpdatedReason {
7979
/// First path that was both read and written during execution.
8080
path: RelativePathBuf,
8181
},
82-
/// fspy isn't compiled in on this build and the task requires fspy
83-
/// (its `input` config includes auto-inference). Task ran but cannot
84-
/// be cached without tracked path accesses.
82+
/// fspy could not provide complete file tracking for this task. Task ran
83+
/// but cannot be cached without complete path accesses.
8584
FspyUnsupported,
8685
/// The runner's IPC server failed during execution, so the collected
8786
/// reports may be incomplete. Caching such a run would risk stale

crates/vite_task/src/session/execute/cache_update.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ use crate::{
2727
/// cfg-agnostic so the decision logic below doesn't need `cfg(fspy)` — the
2828
/// value is only ever `Some` when tracking happened (see [`observe_fspy`]).
2929
struct TrackingOutcome {
30+
/// fspy reported that a descendant process could not be tracked fully.
31+
incomplete: bool,
32+
3033
path_reads: HashMap<RelativePathBuf, PathRead>,
3134
/// Auto-output writes after output exclusions are applied. Empty when
3235
/// `output_config.includes_auto` is false.
@@ -98,6 +101,12 @@ pub(super) async fn update_cache(
98101
workspace_root,
99102
);
100103

104+
if let Some(TrackingOutcome { incomplete: true, .. }) = &fspy_outcome {
105+
// Task ran successfully, but fspy could not fully observe a descendant
106+
// process. A cache entry would risk stale hits, so skip updating.
107+
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None);
108+
}
109+
101110
if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome {
102111
// fspy-inferred read-write overlap: the task wrote to a file it also
103112
// read, so the prerun input hashes are stale and caching is unsound.
@@ -246,6 +255,7 @@ fn observe_fspy(
246255
let read_write_overlap =
247256
filtered_path_reads.keys().find(|p| filtered_path_writes.contains(*p)).cloned();
248257
TrackingOutcome {
258+
incomplete: tracked.incomplete,
249259
path_reads: filtered_path_reads,
250260
path_writes: filtered_path_writes,
251261
read_write_overlap,

crates/vite_task/src/session/execute/tracked_accesses.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ use crate::collections::HashMap;
1717
/// Tracked file accesses from fspy, normalized to workspace-relative paths.
1818
#[derive(Default, Debug)]
1919
pub struct TrackedPathAccesses {
20+
/// fspy observed a descendant exec that could not be tracked completely.
21+
pub incomplete: bool,
22+
2023
/// Tracked path reads
2124
pub path_reads: HashMap<RelativePathBuf, PathRead>,
2225

@@ -31,6 +34,11 @@ impl TrackedPathAccesses {
3134
pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self {
3235
let mut accesses = Self::default();
3336
for access in raw.iter() {
37+
if access.mode.contains(AccessMode::UNTRACKED_EXEC) {
38+
accesses.incomplete = true;
39+
continue;
40+
}
41+
3442
// Strip workspace root and clean `..` components in one pass.
3543
// fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`.
3644
let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| {

crates/vite_task/src/session/reporter/summary.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,13 +542,13 @@ impl TaskResult {
542542
{
543543
return vite_str::format!("→ Not cached: read and wrote '{path}'");
544544
}
545-
// fspy-unsupported-on-this-OS message — same overrides precedence as above
545+
// fspy-incomplete message — same overrides precedence as above
546546
if let Self::Spawned {
547547
outcome: SpawnOutcome::Success { fspy_unsupported: true, .. }, ..
548548
} = self
549549
{
550550
return Str::from(
551-
"→ Not cached: `input` auto-inference isn't supported on this OS. Configure `input` manually to enable caching.",
551+
"→ Not cached: `input` auto-inference could not fully track this task. Configure `input` manually to enable caching.",
552552
);
553553
}
554554

0 commit comments

Comments
 (0)