Skip to content

feat: improve concurrency safety, cross-platform support and dynamic versioning#6

Open
Fahl-Design wants to merge 1 commit intomainfrom
feat/concurrency-and-versioning
Open

feat: improve concurrency safety, cross-platform support and dynamic versioning#6
Fahl-Design wants to merge 1 commit intomainfrom
feat/concurrency-and-versioning

Conversation

@Fahl-Design
Copy link
Member

@Fahl-Design Fahl-Design commented Mar 6, 2026

Summary of Changes

1. Shell Update & Session Isolation (Root Cause Fix)

  • Fixed PVM_DIR Export: The pvm env command now explicitly exports PVM_DIR, ensuring the shell wrapper can correctly locate its configuration files.
  • Session-Specific Updates: Implemented isolated environment update files using the shell's PID (e.g., .env_update_1234). This prevents race conditions and "version bleeding" between different terminal tabs.
  • Safety Guards: Added directory presence checks in shell wrappers to prevent rm or cat operations on invalid paths.
  • Silent Operations: Bypassed user-defined aliases by using command rm and redirected all temporary file cleanup output to /dev/null.

2. Concurrency & Reliability

  • File Locking: Integrated the fs4 crate to implement advisory file locking on shared state files (.env_update, .update_check_guard, remote_cache.json). This ensures data integrity when multiple PVM instances run simultaneously.
  • JSON API Integration: Refactored network.rs to use the remote JSON API instead of HTML scraping. This is faster, more robust, and allowed for the removal of the scraper dependency.
  • Enhanced Progress Reporting: Added a real-time download progress bar with accurate byte counts and ETA for PHP version installations.

3. Cross-Platform Support

  • Dynamic Detection: Replaced hardcoded "linux-x86_64" strings with dynamic OS and Architecture detection (std::env::consts). PVM now natively supports macOS (Intel/Silicon) and ARM64 Linux (e.g., Raspberry Pi).

4. Modernization & Maintainability

  • Dynamic Versioning: Created a build.rs script that injects the current Git commit ID and build timestamp into the version string.
  • Robust Versioning: Integrated the semver crate for standard-compliant version parsing and sorting, replacing brittle manual string splitting.
  • Centralized Constants: Moved all magic strings and environment variable names into a single src/constants.rs file.
  • Dependency Audit: Updated all dependencies to their latest stable versions and removed unused crates (scraper, regex, thiserror, log).

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for per-project PHP version tracking via configuration file
    • Implemented optimized update checks to run at most once every 24 hours
  • Improvements

    • Enhanced version listing and sorting for better reliability
    • Improved atomic file operations for safer environment configurations
    • Updated dependencies for improved stability

@Fahl-Design Fahl-Design self-assigned this Mar 6, 2026
…versioning

This change introduces:
- File locking for shared state files using fs4
- Dynamic versioning with commit ID and build time via build.rs
- Cross-platform support for macOS and ARM architectures
- Session-specific environment updates using PVM_SHELL_PID
- Safety guards and silent removal of temp files in shell wrappers
- Centralized constants and semantic version sorting logic
- Bug fixes in interactive menu and shell file discovery
@Fahl-Design Fahl-Design force-pushed the feat/concurrency-and-versioning branch from 9d28092 to e213c57 Compare March 6, 2026 20:53
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

This PR refactors environment variable and file handling across the codebase by introducing centralized constants, implementing file locking for concurrent access, switching from HTML to JSON-based remote version listing, and adding a compile-time version tracking system via a new build script.

Changes

Cohort / File(s) Summary
Configuration & Build
.php-version, Cargo.toml, build.rs
Added PHP version file, updated dependencies (added fs4, futures-util; updated tokio, reqwest), and introduced a new build script that computes PVM_VERSION at compile time using Git commit hash and UTC timestamp with CI/local fallback logic.
Constants & Utilities
src/constants.rs, src/utils.rs, src/main.rs
Created new constants module defining 7 public string constants for environment variables, file names, and base URL; added utils module with sort_versions function for semantic and numeric version sorting; declared both modules in main.rs.
CLI & Version Display
src/cli.rs
Changed version source for CLI help from CARGO_PKG_VERSION to PVM_VERSION environment variable.
File System Operations
src/fs.rs
Replaced hard-coded environment variable names with constants, delegated version sorting to centralized utility, and introduced get_env_update_path function for per-shell-env update file paths.
Command Handlers
src/commands/env.rs, src/commands/install.rs, src/commands/use_cmd.rs
Adopted constants for environment variable names; added atomic file writes with exclusive locking using fs4; implemented cross-platform PATH separator handling; updated environment variable setup and propagation.
Network & Remote Version Listing
src/network.rs
Replaced HTML scraping with JSON-based remote version listing; added file locking for cache reads/writes; introduced streaming download with progress bar; filters versions by target-specific suffixes; enhanced error context messages.
Shell Integration
src/shell.rs
Refactored shell wrapper templates to use dynamic env-file workflow with PVM_DIR directory checks; added per-shell-PID env-file paths; updated PATH exports to use templated PVM_DIR variable; applied changes consistently across Bash, Zsh, and Fish implementations.
Update Mechanism
src/update.rs
Introduced guarded update check using a lockable 24-hour guard file; replaced existence-based check with timestamp-based content check; added exclusive file locking during guard file writes.
Tests
tests/cli.rs
Updated env_bash test to assert PVM_DIR environment variable export in addition to PATH.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hop, hop! Constants now reign supreme,
File locks protect each env dream,
JSON flows where HTML once stood,
Build-time versioning—understood!
From chaos to order, organized and clean. 🌱

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the three main themes of the changeset: concurrency safety (file locking, session isolation), cross-platform support (OS/arch detection), and dynamic versioning (build.rs injection, semver integration).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/concurrency-and-versioning

Comment @coderabbitai help to get the list of available commands and usage tips.

@Fahl-Design
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Cargo.toml (1)

4-4: ⚠️ Potential issue | 🟡 Minor

Ensure CI and local toolchains support Rust 1.85.0 or later.

The edition = "2024" requires rustc 1.85.0 (stable) or later. Verify your CI pipeline and development environment are configured to use this minimum version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Cargo.toml` at line 4, The Cargo.toml now sets edition = "2024" which
requires rustc 1.85.0+; update your toolchain configuration and CI to pin/use
Rust 1.85.0 or later (e.g., update any rust-toolchain, GitHub Actions workflow
steps, CI images, and Dockerfiles) so local and CI builds match the new edition;
verify rustup/toolchain settings and CI matrix entries reference 1.85.0+ and
rerun the pipeline to confirm compatibility.
🧹 Nitpick comments (3)
build.rs (2)

20-20: CI detection is limited to GitHub Actions.

The GITHUB_ACTIONS check excludes other CI environments (GitLab CI, CircleCI, Jenkins, etc.). Consider using the more universal CI environment variable, which is set by most CI systems.

💡 Proposed broader CI detection
-    let is_ci = std::env::var("GITHUB_ACTIONS").is_ok();
+    // CI env var is set by most CI systems (GitHub Actions, GitLab CI, CircleCI, Travis, etc.)
+    let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.rs` at line 20, The CI detection currently sets is_ci by checking
GITHUB_ACTIONS only; update it to detect a wider range of CI environments by
checking the generic "CI" env var (and optionally fall back to other CI-specific
vars) instead of or in addition to "GITHUB_ACTIONS" so is_ci becomes true for
most CI systems; locate the declaration of the is_ci variable and replace the
std::env::var("GITHUB_ACTIONS").is_ok() expression with a check for
std::env::var("CI").is_ok() (or a small OR chain of known CI vars) to broaden
detection.

3-41: Add cargo:rerun-if-changed directives for build efficiency.

Without rerun directives, this build script executes on every compilation, invoking git and date commands unnecessarily. Adding directives improves incremental build times.

⚡ Proposed fix to add rerun directives
 fn main() {
+    // Only rerun if git state or Cargo.toml changes
+    println!("cargo:rerun-if-changed=.git/HEAD");
+    println!("cargo:rerun-if-changed=.git/refs/heads/");
+    println!("cargo:rerun-if-changed=Cargo.toml");
+
     let commit_hash = Command::new("git")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.rs` around lines 3 - 41, In main(), add cargo rerun directives so the
build script only reruns when relevant inputs change: emit
println!("cargo:rerun-if-changed=.git/HEAD") and
println!("cargo:rerun-if-changed=.git/refs/heads") (or other git files you rely
on) to avoid invoking git/date on every compile, and emit environment-change
directives like println!("cargo:rerun-if-env-changed=GITHUB_ACTIONS") and
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION") so changes to those
vars or package version trigger a rerun; place these near the top of main()
before computing commit_hash, build_time, and version.
src/utils.rs (1)

10-18: Mixed semver/non-semver comparison may produce inconsistent ordering.

When one string is valid semver and the other is not, both are compared using the numeric fallback, discarding valid semver metadata. For example, comparing "8.2.0-alpha" (valid semver with prerelease) against "8.2" (invalid semver) uses the fallback, yielding [8,2,0] vs [8,2], treating the prerelease as greater—contradicting semver semantics where 8.2.0-alpha < 8.2.0.

Consider handling the mixed case explicitly or documenting this limitation.

💡 Proposed fix to handle mixed case
         match (a_sem, b_sem) {
             (Ok(av), Ok(bv)) => av.cmp(&bv),
+            (Ok(_), Err(_)) => std::cmp::Ordering::Greater, // valid semver sorts after non-semver
+            (Err(_), Ok(_)) => std::cmp::Ordering::Less,
             _ => {
                 // Fallback for non-semver strings (e.g., "8.2")
                 let a_parts: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
                 let b_parts: Vec<u32> = b.split('.').filter_map(|s| s.parse().ok()).collect();
                 a_parts.cmp(&b_parts)
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils.rs` around lines 10 - 18, The current match over (a_sem, b_sem)
falls through to the numeric fallback for mixed semver/non-semver cases and
loses semver prerelease semantics; update the match to handle mixed cases
explicitly by adding arms for (Ok(av), Err(_)) and (Err(_), Ok(bv)): when one
side parsed as semver, split the non-semver string into numeric parts and
compare major/minor/patch from av (use av.major,av.minor,av.patch) to those
numeric parts; if numeric parts are equal but av has a prerelease, treat the
semver prerelease as less than the non-semver “release” (i.e., return Less), and
mirror the logic for the reversed case using bv, ensuring a and b variables and
the existing match block are used to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.php-version:
- Line 1: The repository includes a committed .php-version file which is
machine-specific and can confuse contributors or cause merge conflicts; remove
the tracked .php-version from the repo (git rm --cached .php-version or delete
and commit), add ".php-version" to .gitignore to prevent re-committing, and if
keeping it intentionally, add a short note in the project README explaining why
this specific PHP version is required; reference the .php-version file when
making these changes.

In `@build.rs`:
- Around line 12-18: The current build_time computation uses
Command::new("date") (see build_time variable in build.rs) which is POSIX-only
and breaks on Windows; replace this shell call with a cross-platform timestamp
from Rust (add chrono as a build-dependency and compute build_time via
chrono::Utc::now().format(...).to_string()) so the timestamp is produced
reliably on all platforms and remove the Command::new("date") logic.

In `@src/commands/install.rs`:
- Around line 81-94: The current install path in install.rs swallows all I/O
errors when writing the env-update file (using .ok() on lock_exclusive,
writeln!, writer.flush, and unlock), causing a reported "successful switch" even
when the write fails; change this block to treat the write as fallible like
src/commands/use_cmd.rs: use crate::fs::get_env_update_path() as you already do,
but propagate errors (replace the .ok() calls with proper Result handling and
use ? to return the error or map it into the function's error type) for
file.open(), file.lock_exclusive(), writeln!(...), writer.flush(), and
file.unlock() so the install command returns an error if the env-update write
fails.

In `@src/commands/use_cmd.rs`:
- Around line 92-98: The code currently calls std::fs::write(PHP_VERSION_FILE,
&version).ok() and then unconditionally prints a success eprintln; change this
to check the Result from std::fs::write (e.g., match or if let Err(e)) so you
only print the success message when write returns Ok, and print or warn
(eprintln) with the error and mention PHP_VERSION_FILE when it fails so the user
knows the pin update was not written; update the write call handling and the
eprintln block around PHP_VERSION_FILE and version.bold() accordingly.

In `@src/fs.rs`:
- Around line 64-72: get_env_update_path currently rebuilds the .env_update
filename from PVM_SHELL_PID which still collides for concurrent backgrounded
invocations; instead accept a per-invocation path from the caller. Change the
signature of get_env_update_path (and any callers in src/shell.rs) to accept an
Option<PathBuf> or &Path (e.g., get_env_update_path(override: Option<&Path>))
and if provided return that path immediately, otherwise fall back to the
existing pvm_dir + PVM_SHELL_PID logic; update the shell wrapper code that
spawns each invocation to create a unique temp filename and pass that PathBuf
into get_env_update_path (or set and pass through an explicit env var like
PVM_ENV_UPDATE_PATH) so each invocation uses a unique .env_update file instead
of reconstructing from PVM_SHELL_PID.

In `@src/network.rs`:
- Around line 109-116: The cache file is currently truncated by File::create
before acquiring the exclusive lock, creating a race; replace the
File::create(&cache_path) call with opening via std::fs::OpenOptions
(create=true, read=true, write=true) so the file is not truncated on open, then
call file.lock_exclusive(), call file.set_len(0) to clear the file while holding
the lock, then create the std::io::BufWriter from the locked file and
write_all/flush, and finally call file.unlock(); keep references to
serde_json::to_string(&versions), pvm_dir, cache_path, lock_exclusive, set_len,
BufWriter, write_all, flush, and unlock when making the change.

In `@src/update.rs`:
- Around line 18-45: The guard file is being truncated at open which makes
read_to_string() always return empty; remove truncate(true) from the OpenOptions
chain used to open the file (the let file =
std::fs::OpenOptions::new()...open(&guard_file)? block) so the existing
timestamp can be read, then after deciding to update, call file.set_len(0) and
rewind before writing the new timestamp (the current set_len(0),
BufWriter::new(&file), writeln!(writer, "{}", now) and writer.flush() sequence)
and keep the existing lock_exclusive()/unlock() usage around the
read-and-possibly-write sequence.

---

Outside diff comments:
In `@Cargo.toml`:
- Line 4: The Cargo.toml now sets edition = "2024" which requires rustc 1.85.0+;
update your toolchain configuration and CI to pin/use Rust 1.85.0 or later
(e.g., update any rust-toolchain, GitHub Actions workflow steps, CI images, and
Dockerfiles) so local and CI builds match the new edition; verify
rustup/toolchain settings and CI matrix entries reference 1.85.0+ and rerun the
pipeline to confirm compatibility.

---

Nitpick comments:
In `@build.rs`:
- Line 20: The CI detection currently sets is_ci by checking GITHUB_ACTIONS
only; update it to detect a wider range of CI environments by checking the
generic "CI" env var (and optionally fall back to other CI-specific vars)
instead of or in addition to "GITHUB_ACTIONS" so is_ci becomes true for most CI
systems; locate the declaration of the is_ci variable and replace the
std::env::var("GITHUB_ACTIONS").is_ok() expression with a check for
std::env::var("CI").is_ok() (or a small OR chain of known CI vars) to broaden
detection.
- Around line 3-41: In main(), add cargo rerun directives so the build script
only reruns when relevant inputs change: emit
println!("cargo:rerun-if-changed=.git/HEAD") and
println!("cargo:rerun-if-changed=.git/refs/heads") (or other git files you rely
on) to avoid invoking git/date on every compile, and emit environment-change
directives like println!("cargo:rerun-if-env-changed=GITHUB_ACTIONS") and
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION") so changes to those
vars or package version trigger a rerun; place these near the top of main()
before computing commit_hash, build_time, and version.

In `@src/utils.rs`:
- Around line 10-18: The current match over (a_sem, b_sem) falls through to the
numeric fallback for mixed semver/non-semver cases and loses semver prerelease
semantics; update the match to handle mixed cases explicitly by adding arms for
(Ok(av), Err(_)) and (Err(_), Ok(bv)): when one side parsed as semver, split the
non-semver string into numeric parts and compare major/minor/patch from av (use
av.major,av.minor,av.patch) to those numeric parts; if numeric parts are equal
but av has a prerelease, treat the semver prerelease as less than the non-semver
“release” (i.e., return Less), and mirror the logic for the reversed case using
bv, ensuring a and b variables and the existing match block are used to locate
the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 632cbd06-1e14-494d-8cb9-2e803f4dbca3

📥 Commits

Reviewing files that changed from the base of the PR and between 544d4d8 and e213c57.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • .php-version
  • Cargo.toml
  • build.rs
  • src/cli.rs
  • src/commands/env.rs
  • src/commands/install.rs
  • src/commands/use_cmd.rs
  • src/constants.rs
  • src/fs.rs
  • src/main.rs
  • src/network.rs
  • src/shell.rs
  • src/update.rs
  • src/utils.rs
  • tests/cli.rs

@@ -0,0 +1 @@
8.4.18 No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider removing .php-version from the repository.

This file is typically generated per-project by the pvm init or pvm use commands. Committing it to the version manager's own repository may:

  1. Confuse users who clone the repo, as pvm might try to use PHP 8.4.18 when working on the tool itself.
  2. Create merge conflicts if developers have different local PHP versions.

Consider adding .php-version to .gitignore instead, or document why it's intentionally included.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.php-version at line 1, The repository includes a committed .php-version
file which is machine-specific and can confuse contributors or cause merge
conflicts; remove the tracked .php-version from the repo (git rm --cached
.php-version or delete and commit), add ".php-version" to .gitignore to prevent
re-committing, and if keeping it intentionally, add a short note in the project
README explaining why this specific PHP version is required; reference the
.php-version file when making these changes.

Comment on lines +12 to +18
let build_time = Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

date command is not cross-platform (Windows incompatible).

The date -u +%Y-%m-%dT%H:%M:%SZ command is POSIX-specific and will fail on Windows, falling back to "unknown". Since the PR aims to improve cross-platform support, consider using Rust's standard library for timestamp generation.

🛠️ Proposed cross-platform fix
-    let build_time = Command::new("date")
-        .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
-        .output()
-        .ok()
-        .and_then(|output| String::from_utf8(output.stdout).ok())
-        .map(|s| s.trim().to_string())
-        .unwrap_or_else(|| "unknown".to_string());
+    // Cross-platform timestamp using chrono or manual formatting
+    let build_time = {
+        use std::time::SystemTime;
+        SystemTime::now()
+            .duration_since(SystemTime::UNIX_EPOCH)
+            .map(|d| {
+                let secs = d.as_secs();
+                // Simple UTC timestamp (consider using chrono for proper formatting)
+                format!("{}", secs)
+            })
+            .unwrap_or_else(|_| "unknown".to_string())
+    };

Alternatively, add the chrono crate as a build dependency for proper ISO 8601 formatting:

[build-dependencies]
chrono = "0.4"
let build_time = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let build_time = Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Cross-platform timestamp using chrono or manual formatting
let build_time = {
use std::time::SystemTime;
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| {
let secs = d.as_secs();
// Simple UTC timestamp (consider using chrono for proper formatting)
format!("{}", secs)
})
.unwrap_or_else(|_| "unknown".to_string())
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.rs` around lines 12 - 18, The current build_time computation uses
Command::new("date") (see build_time variable in build.rs) which is POSIX-only
and breaks on Windows; replace this shell call with a cross-platform timestamp
from Rust (add chrono as a build-dependency and compute build_time via
chrono::Utc::now().format(...).to_string()) so the timestamp is produced
reliably on all platforms and remove the Command::new("date") logic.

Comment on lines +81 to +94
if let Ok(env_file) = crate::fs::get_env_update_path() {
// Atomic write with advisory lock
if let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&env_file)
{
file.lock_exclusive().ok();
let mut writer = std::io::BufWriter::new(&file);
writeln!(writer, "{}\n{}", export_str1, export_str2).ok();
writer.flush().ok();
file.unlock().ok();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't swallow env-update I/O failures.

If this block fails, the install still reports a successful switch, but the wrapper never receives the exports and the parent shell stays on the old PHP. src/commands/use_cmd.rs already treats the same write as fallible; this path should too.

Suggested fix
-                            if let Ok(env_file) = crate::fs::get_env_update_path() {
-                                // Atomic write with advisory lock
-                                if let Ok(file) = std::fs::OpenOptions::new()
-                                    .create(true)
-                                    .write(true)
-                                    .truncate(true)
-                                    .open(&env_file)
-                                {
-                                    file.lock_exclusive().ok();
-                                    let mut writer = std::io::BufWriter::new(&file);
-                                    writeln!(writer, "{}\n{}", export_str1, export_str2).ok();
-                                    writer.flush().ok();
-                                    file.unlock().ok();
-                                }
-                            }
+                            let env_file = crate::fs::get_env_update_path()?;
+                            let file = std::fs::OpenOptions::new()
+                                .create(true)
+                                .write(true)
+                                .truncate(true)
+                                .open(&env_file)?;
+                            file.lock_exclusive()?;
+                            let mut writer = std::io::BufWriter::new(&file);
+                            writeln!(writer, "{}", export_str1)?;
+                            writeln!(writer, "{}", export_str2)?;
+                            writer.flush()?;
+                            file.unlock()?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Ok(env_file) = crate::fs::get_env_update_path() {
// Atomic write with advisory lock
if let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&env_file)
{
file.lock_exclusive().ok();
let mut writer = std::io::BufWriter::new(&file);
writeln!(writer, "{}\n{}", export_str1, export_str2).ok();
writer.flush().ok();
file.unlock().ok();
}
let env_file = crate::fs::get_env_update_path()?;
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&env_file)?;
file.lock_exclusive()?;
let mut writer = std::io::BufWriter::new(&file);
writeln!(writer, "{}", export_str1)?;
writeln!(writer, "{}", export_str2)?;
writer.flush()?;
file.unlock()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/install.rs` around lines 81 - 94, The current install path in
install.rs swallows all I/O errors when writing the env-update file (using .ok()
on lock_exclusive, writeln!, writer.flush, and unlock), causing a reported
"successful switch" even when the write fails; change this block to treat the
write as fallible like src/commands/use_cmd.rs: use
crate::fs::get_env_update_path() as you already do, but propagate errors
(replace the .ok() calls with proper Result handling and use ? to return the
error or map it into the function's error type) for file.open(),
file.lock_exclusive(), writeln!(...), writer.flush(), and file.unlock() so the
install command returns an error if the env-update write fails.

Comment on lines +92 to +98
std::fs::write(PHP_VERSION_FILE, &version).ok();
eprintln!(
"{} Updated {} to {}",
"✓".green(),
PHP_VERSION_FILE,
version.bold()
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't print a successful pin update when the write is ignored.

If .php-version cannot be written, this still prints a success message and the directory pin silently stays stale. At least warn on failure instead of unconditionally reporting success.

Suggested fix
-                std::fs::write(PHP_VERSION_FILE, &version).ok();
-                eprintln!(
-                    "{} Updated {} to {}",
-                    "✓".green(),
-                    PHP_VERSION_FILE,
-                    version.bold()
-                );
+                match std::fs::write(PHP_VERSION_FILE, &version) {
+                    Ok(()) => eprintln!(
+                        "{} Updated {} to {}",
+                        "✓".green(),
+                        PHP_VERSION_FILE,
+                        version.bold()
+                    ),
+                    Err(e) => eprintln!(
+                        "{} Failed to update {}: {}",
+                        "✗".red(),
+                        PHP_VERSION_FILE,
+                        e
+                    ),
+                }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
std::fs::write(PHP_VERSION_FILE, &version).ok();
eprintln!(
"{} Updated {} to {}",
"✓".green(),
PHP_VERSION_FILE,
version.bold()
);
match std::fs::write(PHP_VERSION_FILE, &version) {
Ok(()) => eprintln!(
"{} Updated {} to {}",
"✓".green(),
PHP_VERSION_FILE,
version.bold()
),
Err(e) => eprintln!(
"{} Failed to update {}: {}",
"✗".red(),
PHP_VERSION_FILE,
e
),
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/use_cmd.rs` around lines 92 - 98, The code currently calls
std::fs::write(PHP_VERSION_FILE, &version).ok() and then unconditionally prints
a success eprintln; change this to check the Result from std::fs::write (e.g.,
match or if let Err(e)) so you only print the success message when write returns
Ok, and print or warn (eprintln) with the error and mention PHP_VERSION_FILE
when it fails so the user knows the pin update was not written; update the write
call handling and the eprintln block around PHP_VERSION_FILE and version.bold()
accordingly.

Comment on lines +64 to +72
pub fn get_env_update_path() -> Result<PathBuf> {
let pvm_dir = get_pvm_dir()?;
let shell_pid = std::env::var("PVM_SHELL_PID").unwrap_or_default();
let filename = if shell_pid.is_empty() {
crate::constants::ENV_UPDATE_FILE.to_string()
} else {
format!("{}_{}", crate::constants::ENV_UPDATE_FILE, shell_pid)
};
Ok(pvm_dir.join(filename))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PVM_SHELL_PID still collides across concurrent commands in the same shell.

Every backgrounded pvm invocation from one shell shares the same PID, so both src/fs.rs::get_env_update_path() and the wrappers in src/shell.rs still target the same .env_update_<pid> file. The later command can overwrite or remove the first command's update before it is sourced. Pass a per-invocation path from the wrapper instead of reconstructing it from the shell PID.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fs.rs` around lines 64 - 72, get_env_update_path currently rebuilds the
.env_update filename from PVM_SHELL_PID which still collides for concurrent
backgrounded invocations; instead accept a per-invocation path from the caller.
Change the signature of get_env_update_path (and any callers in src/shell.rs) to
accept an Option<PathBuf> or &Path (e.g., get_env_update_path(override:
Option<&Path>)) and if provided return that path immediately, otherwise fall
back to the existing pvm_dir + PVM_SHELL_PID logic; update the shell wrapper
code that spawns each invocation to create a unique temp filename and pass that
PathBuf into get_env_update_path (or set and pass through an explicit env var
like PVM_ENV_UPDATE_PATH) so each invocation uses a unique .env_update file
instead of reconstructing from PVM_SHELL_PID.

Comment on lines 109 to +116
if let Ok(json) = serde_json::to_string(&versions) {
std::fs::create_dir_all(&pvm_dir).ok();
if let Ok(mut file) = File::create(&cache_path) {
file.write_all(json.as_bytes()).ok();
if let Ok(file) = File::create(&cache_path) {
file.lock_exclusive().ok();
let mut writer = std::io::BufWriter::new(&file);
writer.write_all(json.as_bytes()).ok();
writer.flush().ok();
file.unlock().ok();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Rust, does std::fs::File::create(path) truncate an existing file immediately on open?

💡 Result:

Yes. std::fs::File::create(path) creates the file if it doesn’t exist, and if it does exist it truncates it to length 0 as part of successfully opening it (i.e., immediately on open, before you write anything). This is equivalent to opening with write access plus create(true) and truncate(true). [1] [2]

Sources:
[1] Rust std docs: std::fs::File::create (“will truncate it if it does”) (doc.rust-lang.org)
[2] Rust std docs: std::fs::OpenOptions::truncate (“If a file is successfully opened … it will truncate the file to 0 length”) (doc.rust-lang.org)

Citations:


The cache write truncates before the lock is acquired.

File::create zeroes remote_cache.json before lock_exclusive() runs, leaving a window where concurrent readers/writers observe an empty or partial cache. The lock does not protect the destructive truncation. Open the file without truncation, acquire the lock, then clear and rewrite the contents.

Suggested fix
-        if let Ok(file) = File::create(&cache_path) {
+        if let Ok(file) = std::fs::OpenOptions::new()
+            .create(true)
+            .write(true)
+            .open(&cache_path)
+        {
             file.lock_exclusive().ok();
+            file.set_len(0).ok();
             let mut writer = std::io::BufWriter::new(&file);
             writer.write_all(json.as_bytes()).ok();
             writer.flush().ok();
             file.unlock().ok();
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Ok(json) = serde_json::to_string(&versions) {
std::fs::create_dir_all(&pvm_dir).ok();
if let Ok(mut file) = File::create(&cache_path) {
file.write_all(json.as_bytes()).ok();
if let Ok(file) = File::create(&cache_path) {
file.lock_exclusive().ok();
let mut writer = std::io::BufWriter::new(&file);
writer.write_all(json.as_bytes()).ok();
writer.flush().ok();
file.unlock().ok();
if let Ok(json) = serde_json::to_string(&versions) {
std::fs::create_dir_all(&pvm_dir).ok();
if let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&cache_path)
{
file.lock_exclusive().ok();
file.set_len(0).ok();
let mut writer = std::io::BufWriter::new(&file);
writer.write_all(json.as_bytes()).ok();
writer.flush().ok();
file.unlock().ok();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/network.rs` around lines 109 - 116, The cache file is currently truncated
by File::create before acquiring the exclusive lock, creating a race; replace
the File::create(&cache_path) call with opening via std::fs::OpenOptions
(create=true, read=true, write=true) so the file is not truncated on open, then
call file.lock_exclusive(), call file.set_len(0) to clear the file while holding
the lock, then create the std::io::BufWriter from the locked file and
write_all/flush, and finally call file.unlock(); keep references to
serde_json::to_string(&versions), pvm_dir, cache_path, lock_exclusive, set_len,
BufWriter, write_all, flush, and unlock when making the change.

Comment on lines +18 to +45
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(true)
.open(&guard_file)?;

file.lock_exclusive()?;

let mut contents = String::new();
let mut f = &file;
f.read_to_string(&mut contents).ok();

// Check if 24 hours have passed
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
if guard_file.exists()
&& let Ok(contents) = std::fs::read_to_string(&guard_file)
if !contents.is_empty()
&& let Ok(last_check) = contents.trim().parse::<u64>()
&& now - last_check < 86400
{
file.unlock().ok();
return Ok(None);
}

// Write the new timestamp to prevent spam on next commands
std::fs::write(&guard_file, now.to_string()).ok();
file.set_len(0).ok();
let mut writer = std::io::BufWriter::new(&file);
writeln!(writer, "{}", now).ok();
writer.flush().ok();
file.unlock().ok();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Rust, does std::fs::OpenOptions::new().truncate(true).open(path) truncate the file immediately before any reads from that handle?

💡 Result:

Yes—if the open succeeds with truncation enabled, the file is truncated as part of the open() operation (before you ever get the File handle back), so any reads from that handle will see an empty file (EOF). Rust’s docs say: “If a file is successfully opened with this option set to true, it will truncate the file to 0 length if it already exists.” [1]. This matches the usual OS semantics of O_TRUNC, which truncates “upon successful completion” of the open call [2].

Two important details for your exact code:

  • truncate(true) only works when opened with write access (“The file must be opened with write access for truncate to work.”) [1].
  • Your snippet OpenOptions::new().truncate(true).open(path) doesn’t request read or write access at all, so it won’t do what you intend.

If you do:

OpenOptions::new()
    .read(true)
    .write(true)
    .truncate(true)
    .open(path)

then the truncation happens during open, and you cannot read the old contents afterward. [1]

Sources: [1] Rust std::fs::OpenOptions::truncate docs, [2] Open Group/POSIX open (O_TRUNC) semantics.


The update guard never throttles because the file is truncated before it is read.

OpenOptions::truncate(true) clears the file as part of the open() call, so subsequent read_to_string() always reads an empty file. The 24-hour short-circuit check cannot activate. Remove truncate(true) from the open call, then truncate and rewind the file only after deciding to persist the new timestamp.

Suggested fix
-use std::io::{Read, Write};
+use std::io::{Read, Seek, Write};
...
     let file = std::fs::OpenOptions::new()
         .create(true)
         .read(true)
         .write(true)
-        .truncate(true)
         .open(&guard_file)?;
...
-    f.read_to_string(&mut contents).ok();
+    f.read_to_string(&mut contents)?;
...
-    file.set_len(0).ok();
-    let mut writer = std::io::BufWriter::new(&file);
-    writeln!(writer, "{}", now).ok();
-    writer.flush().ok();
-    file.unlock().ok();
+    file.set_len(0)?;
+    let mut f = &file;
+    f.rewind()?;
+    let mut writer = std::io::BufWriter::new(f);
+    writeln!(writer, "{}", now)?;
+    writer.flush()?;
+    file.unlock()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/update.rs` around lines 18 - 45, The guard file is being truncated at
open which makes read_to_string() always return empty; remove truncate(true)
from the OpenOptions chain used to open the file (the let file =
std::fs::OpenOptions::new()...open(&guard_file)? block) so the existing
timestamp can be read, then after deciding to update, call file.set_len(0) and
rewind before writing the new timestamp (the current set_len(0),
BufWriter::new(&file), writeln!(writer, "{}", now) and writer.flush() sequence)
and keep the existing lock_exclusive()/unlock() usage around the
read-and-possibly-write sequence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant