aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-23 11:48:01 +0900
committernsfisis <nsfisis@gmail.com>2026-08-23 12:00:07 +0900
commit2f07bd12c0c77e8985646a9f21a62a91311d82c0 (patch)
tree92f995eee73d58b38a850f8ba9b0ab09d8a59f3a
parent88b5c4b9c5f7941292b918b6eefe54947c4a0d96 (diff)
downloadphp-shirabe-2f07bd12c0c77e8985646a9f21a62a91311d82c0.tar.gz
php-shirabe-2f07bd12c0c77e8985646a9f21a62a91311d82c0.tar.zst
php-shirabe-2f07bd12c0c77e8985646a9f21a62a91311d82c0.zip
perf(filesystem): delete directories without spawning rm -rf
Composer shells out to `rm -rf` because PHP has no recursive directory removal. Rust has one, and every package installed from a dist archive pays for a removal: the extraction pipeline drops its temporary directory once per package, and uninstalling a package removes its whole tree. The asynchronous path goes through `tokio::fs` rather than `std::fs`, so the walk runs on a blocking thread and the sibling installs the reactor is driving keep making progress, the way they did while the subprocess was working. Installing laravel/laravel (109 packages) from a warm cache drops from 3.85 to 3.19 CPU seconds. The removals run concurrently, so on an idle 16-core machine they never reach the critical path and wall time is unchanged at 1.65 s; pinned to two cores it falls from 2.33 s to 2.20 s. Pruning the 33 dev packages with `install --no-dev`, where whole package trees are removed rather than empty temporary directories, drops from 844 ms to 806 ms even on 16 cores. Windows keeps the `rmdir /S /Q` subprocess, and both platforms keep falling back to `remove_directory_php` when the fast path does not clear the directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/shirabe/src/util/filesystem.rs87
-rwxr-xr-xscripts/bench/install.sh57
2 files changed, 114 insertions, 30 deletions
diff --git a/crates/shirabe/src/util/filesystem.rs b/crates/shirabe/src/util/filesystem.rs
index bca18771..05146fd3 100644
--- a/crates/shirabe/src/util/filesystem.rs
+++ b/crates/shirabe/src/util/filesystem.rs
@@ -116,9 +116,9 @@ impl Filesystem {
/// Recursively remove a directory
pub fn remove_directory(&mut self, directory: impl AsRef<Path>) -> anyhow::Result<bool> {
// TODO(bytes):
- // This path is matched against a regex (remove_edge_cases) and passed to an
- // `rm -rf`/`rmdir` subprocess via the String-based ProcessExecutor, so it has to be
- // representable as UTF-8.
+ // This path is matched against a regex (remove_edge_cases) and, on Windows, passed to an
+ // `rmdir` subprocess via the String-based ProcessExecutor, so it has to be representable as
+ // UTF-8.
let directory = directory.as_ref();
let directory = directory.to_str().ok_or_else(|| {
RuntimeException::new(format!(
@@ -131,24 +131,25 @@ impl Filesystem {
return Ok(r);
}
- let cmd: Vec<String> = if Platform::is_windows() {
- vec![
+ let result = if Platform::is_windows() {
+ // TODO(perf,windows): `std::fs::remove_dir_all` is expected to be faster here too,
+ // but how it differs from the `rmdir` command has to be investigated first.
+ let cmd: Vec<String> = vec![
"rmdir".to_string(),
"/S".to_string(),
"/Q".to_string(),
Platform::realpath(directory),
- ]
+ ];
+
+ let mut output = PhpMixed::Null;
+ self.get_process()
+ .execute(&cmd, &mut output, None)
+ .map(|n| n == 0)
+ .unwrap_or(false)
} else {
- vec!["rm".to_string(), "-rf".to_string(), directory.to_string()]
+ remove_directory_recursively(Path::new(directory)).is_ok()
};
- let mut output = PhpMixed::Null;
- let result = self
- .get_process()
- .execute(&cmd, &mut output, None)
- .map(|n| n == 0)
- .unwrap_or(false);
-
// clear stat cache because external processes aren't tracked by the php stat cache
clearstatcache2(false, "");
@@ -164,7 +165,7 @@ impl Filesystem {
this: &std::rc::Rc<std::cell::RefCell<Filesystem>>,
directory: &str,
) -> anyhow::Result<bool> {
- let (process_executor, cmd) = {
+ {
let mut fs = this.borrow_mut();
if let Some(mock) = fs.mock.as_mut()
@@ -179,28 +180,33 @@ impl Filesystem {
if let Some(r) = edge_case_result {
return Ok(r);
}
+ }
- let cmd: Vec<String> = if Platform::is_windows() {
- vec![
- "rmdir".to_string(),
- "/S".to_string(),
- "/Q".to_string(),
- Platform::realpath(directory),
- ]
- } else {
- vec!["rm".to_string(), "-rf".to_string(), directory.to_string()]
- };
+ let result = if Platform::is_windows() {
+ // TODO(perf,windows): `std::fs::remove_dir_all` is expected to be faster here too,
+ // but how it differs from the `rmdir` command has to be investigated first.
+ let cmd: Vec<String> = vec![
+ "rmdir".to_string(),
+ "/S".to_string(),
+ "/Q".to_string(),
+ Platform::realpath(directory),
+ ];
- (fs.get_process_handle(), cmd)
- };
+ let process_executor = this.borrow_mut().get_process_handle();
+ let process_future = process_executor.borrow_mut().execute_async(&cmd, None);
+ let mut process = process_future.await?;
- let process_future = process_executor.borrow_mut().execute_async(&cmd, None);
- let mut process = process_future.await?;
+ process.is_successful()
+ } else {
+ remove_directory_recursively_async(Path::new(directory))
+ .await
+ .is_ok()
+ };
// clear stat cache because external processes aren't tracked by the php stat cache
clearstatcache2(false, "");
- if process.is_successful() && !is_dir(directory) {
+ if result && !is_dir(directory) {
return Ok(true);
}
@@ -1084,3 +1090,24 @@ impl Filesystem {
result
}
}
+
+/// Removes `path` and everything below it, standing in for the `rm -rf` that Composer shells out
+/// to on non-Windows platforms. Symbolic links below `path` are unlinked rather than followed, and
+/// a `path` that is already gone counts as removed.
+///
+/// Unlike `rm -rf`, this stops at the first entry it cannot remove instead of removing the rest of
+/// the tree first. Callers fall back to `Filesystem::remove_directory_php` on failure, which walks
+/// the remainder and surfaces the error.
+fn remove_directory_recursively(path: &Path) -> std::io::Result<()> {
+ match std::fs::remove_dir_all(path) {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
+ _ => Ok(()),
+ }
+}
+
+async fn remove_directory_recursively_async(path: &Path) -> std::io::Result<()> {
+ match tokio::fs::remove_dir_all(path).await {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
+ _ => Ok(()),
+ }
+}
diff --git a/scripts/bench/install.sh b/scripts/bench/install.sh
new file mode 100755
index 00000000..ef26d273
--- /dev/null
+++ b/scripts/bench/install.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
+BIN="$REPO_ROOT/target/release/shirabe"
+COMPOSER_BIN="$REPO_ROOT/composer/bin/composer"
+
+source "$REPO_ROOT/scripts/bench/lib.sh"
+
+PACKAGE="laravel/laravel"
+for arg in "$@"; do
+ case "$arg" in
+ --package=*) PACKAGE="${arg#*=}" ;;
+ *) echo "unknown option: $arg" >&2; exit 2 ;;
+ esac
+done
+
+if ! command -v hyperfine >/dev/null 2>&1; then
+ echo "hyperfine not found; install it first (e.g. 'cargo install hyperfine')" >&2
+ exit 1
+fi
+
+cargo build --manifest-path "$REPO_ROOT/Cargo.toml" --release --bin shirabe
+
+apply_http3_workaround
+
+OUTDIR="${TMPDIR:-/tmp}/shirabe-bench-install-$$"
+mkdir -p "$OUTDIR"
+cd "$OUTDIR"
+
+TARGET_DIR="project-under-test"
+PACKAGE_SLUG="${PACKAGE//\//-}"
+
+# Resolve the dependencies once. What is measured is installing them, not solving them.
+"$COMPOSER_BIN" create-project --no-plugins --no-scripts --no-audit --no-interaction \
+ "$PACKAGE" "$OUTDIR/template" >/dev/null
+
+PREPARE_SCRIPT="$OUTDIR/prepare.sh"
+cat > "$PREPARE_SCRIPT" <<EOF
+for dir in '$TARGET_DIR-shirabe' '$TARGET_DIR-composer'; do
+ rm -rf "\$dir"
+ mkdir -p "\$dir"
+ cp '$OUTDIR/template/composer.json' '$OUTDIR/template/composer.lock' "\$dir/"
+done
+EOF
+
+hyperfine \
+ --warmup 1 \
+ --prepare "bash '$PREPARE_SCRIPT'" \
+ --export-json "$OUTDIR/results-$PACKAGE_SLUG.json" \
+ --export-markdown "$OUTDIR/results-$PACKAGE_SLUG.md" \
+ --command-name Shirabe "RUST_BACKTRACE=1 '$BIN' install --no-plugins --no-scripts --no-interaction --working-dir='$TARGET_DIR-shirabe'" \
+ --command-name Composer "'$COMPOSER_BIN' install --no-plugins --no-scripts --no-interaction --working-dir='$TARGET_DIR-composer'"
+
+echo ">> results: $OUTDIR/results-$PACKAGE_SLUG.json" >&2
+echo ">> $OUTDIR/results-$PACKAGE_SLUG.md" >&2