Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)).
- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825)).
- **Added** Vite+ now builds and runs on platforms without file-access tracking support, such as FreeBSD. On those platforms, automatic input and output tracking is unavailable, so tasks that rely on it report that tracking is unsupported and must configure their `input` and `output` manually to be cached ([#829](https://github.com/voidzero-dev/vite-task/pull/829)).
- **Changed** The `vp run` summary now reads like `3/4 cache hit (75%), 9/10 successful, 2s saved.` The cache hit count leaves out tasks with caching disabled, and the count of successful tasks out of all tasks replaces the count of failed tasks. When no task has caching enabled, the summary shows only the successful count. In `vp run --verbose` and `vp run --last-details`, the cache hit rate also leaves out tasks with caching disabled, and says `no task has caching enabled` when there are none ([#821](https://github.com/voidzero-dev/vite-task/pull/821)).
Expand Down
92 changes: 92 additions & 0 deletions crates/fspy/tests/node_sea.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
mod test_utils;

use std::{
env::var_os,
fs,
path::{Path, PathBuf},
process::Command,
};

use fspy::AccessMode;
use test_log::test;
use test_utils::assert_contains;

fn run(command: &mut Command) -> anyhow::Result<Vec<u8>> {
let output = command.output()?;
anyhow::ensure!(
output.status.success(),
"{command:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(output.stdout)
}

/// Builds a Node.js single executable application that reads the file named
/// by its first argument.
/// <https://nodejs.org/api/single-executable-applications.html>
fn build_node_sea(dir: &Path) -> anyhow::Result<PathBuf> {
let manifest_dir = PathBuf::from(var_os("CARGO_MANIFEST_DIR").unwrap());
let postject = manifest_dir
.parent()
.unwrap()
.parent()
.unwrap()
.join("packages/tools/node_modules/postject/dist/cli.js");

// `node` on PATH may be a version manager shim; copy the real binary.
let node = PathBuf::from(
String::from_utf8(run(Command::new("node").args(["-p", "process.execPath"]))?)?.trim_end(),
);

fs::write(dir.join("main.js"), "require('node:fs').readFileSync(process.argv[2]);")?;
fs::write(
dir.join("sea-config.json"),
r#"{ "main": "main.js", "output": "sea-prep.blob", "disableExperimentalSEAWarning": true }"#,
)?;
run(Command::new(&node)
.arg("--experimental-sea-config")
.arg("sea-config.json")
.current_dir(dir))?;

let sea = dir.join(if cfg!(windows) { "sea.exe" } else { "sea" });
fs::copy(&node, &sea)?;
#[cfg(target_os = "macos")]
run(Command::new("codesign").arg("--remove-signature").arg(&sea))?;

let mut inject = Command::new(&node);
inject.arg(postject).arg(&sea).args([
"NODE_SEA_BLOB",
"sea-prep.blob",
"--sentinel-fuse",
"NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
]);
#[cfg(target_os = "macos")]
inject.args(["--macho-segment-name", "NODE_SEA"]);
run(inject.current_dir(dir))?;

#[cfg(target_os = "macos")]
run(Command::new("codesign").args(["--sign", "-"]).arg(&sea))?;
Ok(sea)
}

// On macOS, the injected preload must not hide the embedded application from
// Node's lookup.
#[test(tokio::test)]
#[ignore = "requires node and `pnpm install` (workspace root)"]
async fn node_sea() -> anyhow::Result<()> {
let tmpdir = tempfile::tempdir()?;
// on macOS, tmpdir.path() may be a symlink, so we need to canonicalize it
let tmpdir_path = fs::canonicalize(tmpdir.path())?;
let sea = build_node_sea(&tmpdir_path)?;
let input = tmpdir_path.join("input.txt");
fs::write(&input, "")?;

let mut command = fspy::Command::new(&sea);
command.arg(&input);
let termination =
command.spawn(tokio_util::sync::CancellationToken::new()).await?.wait_handle.await?;
assert!(termination.status.success(), "{:?}", termination.status);
let accesses = termination.path_accesses.expect("the test region holds every record");
assert_contains(&accesses, &input, AccessMode::READ);
Ok(())
}
68 changes: 68 additions & 0 deletions crates/fspy_preload_unix/src/interceptions/dyld.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! dyld lists `DYLD_INSERT_LIBRARIES` images before the main executable, so
//! injecting the preload moves the main executable away from image index 0.
//! Code that assumes index 0 is the main executable then reads another
//! image's header or slide: Node.js single executable applications (via
//! postject's `getsectdata(..) + _dyld_get_image_vmaddr_slide(0)`) and
//! `deno compile` outputs fail to find their embedded payload. These
//! interposers present the main executable at index 0, followed by the
//! inserted libraries.
//!
//! dyld moves inserted libraries ahead of the main executable "for correct
//! flat namespace lookups" ([`RuntimeState::loadInsertedLibraries`]) and
//! applies the same permutation itself, but only for `x86_64` apps targeting
//! macOS before 12 ([`normalizeImageIndex`]). Where dyld already applies it,
//! the main executable is at index 0 and the mapping below is the identity.
//!
//! [`RuntimeState::loadInsertedLibraries`]: https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/dyld/DyldRuntimeState.cpp#L676-L680
//! [`normalizeImageIndex`]: https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/dyld/DyldAPIs.cpp#L238-L253

use crate::{
libc::{c_char, intptr_t, mach_header},
macros::intercept,
};

/// `MH_EXECUTE` from `<mach-o/loader.h>`.
const MH_EXECUTE: u32 = 0x2;

/// Maps an index in the presented order to dyld's actual index.
///
/// The main executable is looked up on every call rather than cached: its
/// index stays fixed only because of undocumented dyld behavior.
fn actual_image_index(image_index: u32) -> u32 {
let mut main_index = 0;
loop {
// SAFETY: dyld returns null for out-of-range indices.
let header = unsafe { _dyld_get_image_header::original()(main_index) };
if header.is_null() {
return image_index;
}
// SAFETY: a non-null header points to the loaded image's mach header.
if unsafe { (*header).filetype } == MH_EXECUTE {
break;
}
main_index += 1;
}
match image_index {
0 => main_index,
index if index <= main_index => index - 1,
index => index,
}
}

intercept!(_dyld_get_image_header: unsafe extern "C" fn(image_index: u32) -> *const mach_header);
unsafe extern "C" fn _dyld_get_image_header(image_index: u32) -> *const mach_header {
// SAFETY: forwarding to the original dyld function with a remapped index.
unsafe { _dyld_get_image_header::original()(actual_image_index(image_index)) }
}

intercept!(_dyld_get_image_vmaddr_slide: unsafe extern "C" fn(image_index: u32) -> intptr_t);
unsafe extern "C" fn _dyld_get_image_vmaddr_slide(image_index: u32) -> intptr_t {
// SAFETY: forwarding to the original dyld function with a remapped index.
unsafe { _dyld_get_image_vmaddr_slide::original()(actual_image_index(image_index)) }
}

intercept!(_dyld_get_image_name: unsafe extern "C" fn(image_index: u32) -> *const c_char);
unsafe extern "C" fn _dyld_get_image_name(image_index: u32) -> *const c_char {
// SAFETY: forwarding to the original dyld function with a remapped index.
unsafe { _dyld_get_image_name::original()(actual_image_index(image_index)) }
}
2 changes: 2 additions & 0 deletions crates/fspy_preload_unix/src/interceptions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod access;
mod dirent;
#[cfg(target_os = "macos")]
mod dyld;
mod open;
mod spawn;
mod stat;
Expand Down
22 changes: 22 additions & 0 deletions crates/fspy_preload_unix/src/libc.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
pub use libc::*;

/// The leading fields of `mach_header` from `<mach-o/loader.h>`, which
/// `libc` deprecates in favor of the `mach2` crate.
#[cfg(target_os = "macos")]
#[repr(C)]
pub struct mach_header {
pub magic: u32,
pub cputype: i32,
pub cpusubtype: i32,
pub filetype: u32,
}

unsafe extern "C" {
// On macOS x86_64, directory functions use $INODE64 symbol suffix for 64-bit inode support.
// On arm64, 64-bit inodes are the only option so no suffix is needed.
Expand Down Expand Up @@ -43,4 +54,15 @@ unsafe extern "C" {
buf_len: usize,
basep: *mut i64,
) -> isize;

// Declared here because `libc` deprecates its dyld bindings in favor of
// the `mach2` crate.
#[cfg(target_os = "macos")]
pub unsafe fn _dyld_get_image_header(image_index: u32) -> *const mach_header;

#[cfg(target_os = "macos")]
pub unsafe fn _dyld_get_image_vmaddr_slide(image_index: u32) -> intptr_t;

#[cfg(target_os = "macos")]
pub unsafe fn _dyld_get_image_name(image_index: u32) -> *const c_char;
}
1 change: 1 addition & 0 deletions packages/tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"oxlint-tsgolint": "catalog:",
"playwright": "catalog:",
"pnpm": "catalog:",
"postject": "catalog:",
"typescript": "7.1.0-dev.20261001.1",
"vite": "catalog:",
"vitest": "catalog:"
Expand Down
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ catalog:
playwright: 1.63.0
# The e2e tests run this pnpm; keep it in sync with `packageManager`.
pnpm: 11.24.0
postject: 1.0.0-alpha.6
typescript: ^6.0.3
vite: npm:@voidzero-dev/vite-plus-core@1.1.0
vite-plus: 1.1.0
Expand Down
Loading