diff --git a/crates/uffs-mft/src/reader/index_read.rs b/crates/uffs-mft/src/reader/index_read.rs index bd682d6f0..7a2753a5d 100644 --- a/crates/uffs-mft/src/reader/index_read.rs +++ b/crates/uffs-mft/src/reader/index_read.rs @@ -803,7 +803,13 @@ impl MftReader { // 1. Open `X:\$MFT` as a file (filesystem-mediated) // 2. Re-open volume with FILE_FLAG_NO_BUFFERING (bypasses cache manager, // direct device I/O) - warn!( + // INFO, not WARN: the cascade below is designed, + // automatically-handled resilience — a normal run + // stays quiet (WARN-default subscribers show + // nothing) and `-v`/verbose reveals the chain when + // debugging. If every fallback fails too, the `?` + // propagates a real error. + info!( volume = %self.volume, error = %iocp_err, "⚠️ IOCP inline read failed — trying fallback strategies" @@ -920,7 +926,10 @@ impl MftReader { return result; } Err(err) => { - warn!( + // INFO for the same reason as the IOCP-failure line: an + // intermediate rung of the handled fallback chain, not an + // operator-actionable warning. + info!( volume = %self.volume, error = %err, "⚠️ Fallback 1 ($MFT file) failed — trying unbuffered volume I/O" diff --git a/just/build.just b/just/build.just index 7d558fb3b..5e61a7103 100644 --- a/just/build.just +++ b/just/build.just @@ -123,75 +123,22 @@ copy-binary profile: done if ! echo "$PATH" | grep -q "$HOME/bin"; then printf "\033[1;33m⚠️ ~/bin not in PATH. Add this to your shell profile:\033[0m\n"; printf " export PATH=\"\$HOME/bin:\$PATH\"\n"; fi -# Build the entire workspace (release) locally and copy ALL binary artifacts -# to ~/bin. One `cargo build --release --workspace` does the heavy lifting; -# then every binary in target/release/ is installed. +# Build the whole workspace from source and install every binary to ~/bin. # # Use this when you want to test in-development code. Prefer `just use` # when you want the reproducible, GitHub-built release binaries — those # bytes are what your end users download. # +# Body is a rust-script (scripts/dev/install-bins.rs) so it runs identically +# under just's unix shell and its powershell windows-shell — a just shebang +# recipe eats Windows temp-path backslashes, and a `bash script.sh` line hits +# WSL's cargo-less bash. The script stops the running daemon/MCP first, +# builds once, then installs every binary cargo reports having built +# (auto-discovered — a new [[bin]] anywhere is picked up automatically). +# # Build workspace from source and install all binaries to ~/bin (dev). -[unix] use-local: - #!/usr/bin/env bash - set -euo pipefail - printf "\033[1;34m📦 Build + install ALL UFFS binaries to ~/bin\033[0m\n" - echo "========================================================" - - # 0. Stop the running daemon + MCP so the old binaries release their locks - # and no stale in-memory index shadows the freshly installed build. - printf "\n\033[1;33m🔪 Stopping daemon + MCP...\033[0m\n" - timeout 10 uffs --daemon kill >/dev/null 2>&1 || true - pkill -x uffsd 2>/dev/null || true - pkill -x uffsmcp 2>/dev/null || true - - # 1. Single workspace-wide release build - printf "\n\033[0;34m🔨 Building release workspace...\033[0m\n" - START=$(date +%s) - cargo build --release --workspace - END=$(date +%s) - printf "\033[0;32m → Built in %ds\033[0m\n" $((END - START)) - - # 2. Copy every binary artifact to ~/bin - BIN_DIR="$HOME/bin" - mkdir -p "$BIN_DIR" - INSTALLED=0 - printf "\n\033[0;34m📦 Installing to %s\033[0m\n" "$BIN_DIR" - for SRC in target/release/*; do - [[ -f "$SRC" && -x "$SRC" ]] || continue - NAME=$(basename "$SRC") - # Skip non-binaries (shared libs, build scripts, deps fingerprints, etc.) - [[ "$NAME" == *.d ]] && continue - [[ "$NAME" == *.dylib ]] && continue - [[ "$NAME" == *.so ]] && continue - [[ "$NAME" == *.rlib ]] && continue - # Skip if it contains a dot (e.g. libfoo.a) — real binaries have no extension on Unix - [[ "$NAME" == *.* ]] && continue - DEST="$BIN_DIR/$NAME" - # If a directory exists with the same name, remove it first - if [[ -d "$DEST" ]]; then - printf " \033[1;33m⚠️ Removing stale directory %s\033[0m\n" "$DEST" - rm -rf "$DEST" - fi - # rm -f before cp so the new file gets a fresh inode — overwrites in - # place share the inode, which lets macOS re-use a path-cached launch - # services deny verdict against an earlier broken copy at the same path. - rm -f "$DEST" - cp "$SRC" "$DEST" - chmod +x "$DEST" - SIZE=$(du -sh "$BIN_DIR/$NAME" | cut -f1) - printf " \033[0;32m✅ %-30s %s\033[0m\n" "$NAME" "$SIZE" - INSTALLED=$((INSTALLED + 1)) - done - - printf "\n\033[0;32m✅ Installed %d binaries to ~/bin\033[0m\n" "$INSTALLED" - - # Check PATH - if ! echo "$PATH" | grep -q "$HOME/bin"; then - printf "\n\033[1;33m⚠️ ~/bin not in PATH. Add to your shell profile:\033[0m\n" - printf " export PATH=\"\$HOME/bin:\$PATH\"\n" - fi + rust-script scripts/dev/install-bins.rs # Install UFFS binaries from GitHub Release (cached in dist/) to ~/bin. # diff --git a/scripts/dev/install-bins.rs b/scripts/dev/install-bins.rs new file mode 100755 index 000000000..38f0ce712 --- /dev/null +++ b/scripts/dev/install-bins.rs @@ -0,0 +1,252 @@ +#!/usr/bin/env rust-script +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +//! +//! install-bins.rs — build the workspace and install **every** binary it +//! produces to `~/bin`. +//! +//! The binary list is not hardcoded: after building, the same build is +//! re-run with `--message-format=json` (instant from cache) and every +//! `compiler-artifact` message's `executable` path is installed. A new +//! `[[bin]]` anywhere in the workspace is picked up automatically. +//! +//! Called by `just use-local` as +//! `rust-script scripts/dev/install-bins.rs`. A rust-script on purpose: +//! +//! - a just shebang recipe dies on Windows (just hands bash a raw +//! `C:\...` temp path whose backslashes bash eats, exit 127); +//! - a plain `bash script.sh` line dies too when `bash` on PATH resolves +//! to WSL's `System32\bash.exe`, which has no cargo ("cargo: command +//! not found"). +//! +//! rust-script runs identically under just's unix shell and its +//! powershell windows-shell, and spawns `cargo` from the real PATH. +//! +//! Usage: +//! rust-script scripts/dev/install-bins.rs + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn main() { + let build_args: &[&str] = &["build", "--release", "--workspace"]; + + eprintln!("📦 Build (release) + install UFFS binaries to ~/bin"); + eprintln!("========================================================"); + + // Stop the running daemon + MCP first (best effort), mirroring the + // previous [unix] bash recipe: the old binaries release their file + // locks (Windows can't overwrite a running .exe at all) and no stale + // in-memory index shadows the freshly installed build. + stop_running_services(); + + eprintln!(); + eprintln!("🔨 cargo {}", build_args.join(" ")); + + let started = Instant::now(); + let status = Command::new("cargo").args(build_args).status(); + match status { + Ok(code) if code.success() => { + eprintln!(" → Built in {}s", started.elapsed().as_secs()); + } + Ok(code) => { + eprintln!("❌ build failed with {code:?}"); + std::process::exit(1); + } + Err(err) => { + eprintln!("❌ failed to launch cargo: {err}"); + std::process::exit(1); + } + } + + // EVERY binary the workspace just built — discovered from cargo, not + // a hardcoded list, so a new bin target is picked up automatically and + // nothing is ever silently missed. `--message-format=json` re-emits + // one `compiler-artifact` message per crate straight from the build + // cache (instant, since the real build above already ran), and each + // binary target carries a non-null `"executable"` path. Libraries + // carry `"executable":null` and are skipped. + let executables = workspace_executables(build_args); + if executables.is_empty() { + eprintln!("❌ cargo reported no workspace binaries to install"); + std::process::exit(1); + } + + let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let Ok(home) = std::env::var(home_var) else { + eprintln!("❌ {home_var} is not set — nowhere to install to"); + std::process::exit(1); + }; + let bin_dir = PathBuf::from(&home).join("bin"); + if let Err(err) = std::fs::create_dir_all(&bin_dir) { + eprintln!("❌ cannot create {}: {err}", bin_dir.display()); + std::process::exit(1); + } + + let mut installed = 0_u32; + let mut skipped = 0_u32; + eprintln!(); + eprintln!("📦 Installing {} binaries to {}", executables.len(), bin_dir.display()); + for src in &executables { + let Some(file_name) = src.file_name() else { + continue; + }; + let dest = bin_dir.join(file_name); + let name = file_name.to_string_lossy(); + if !src.is_file() { + eprintln!(" ⚠️ {name:<28} not found at {} (skipping)", src.display()); + skipped += 1; + continue; + } + // Remove first so the copy gets a fresh inode — overwrites in place + // share the inode, which lets macOS re-use a path-cached Launch + // Services deny verdict against an earlier broken copy. + if dest.is_dir() { + let _ = std::fs::remove_dir_all(&dest); + } + let _ = std::fs::remove_file(&dest); + match std::fs::copy(src, &dest) { + Ok(bytes) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions( + &dest, + std::fs::Permissions::from_mode(0o755), + ); + } + eprintln!(" ✅ {name:<28} {:.1} MB", bytes as f64 / 1_048_576.0); + installed += 1; + } + Err(err) => { + eprintln!(" ❌ {name:<28} copy failed: {err}"); + skipped += 1; + } + } + } + + eprintln!(); + eprintln!("✅ Installed {installed} binaries ({skipped} skipped)"); + let on_path = std::env::var("PATH") + .map(|path| std::env::split_paths(&path).any(|entry| entry == bin_dir)) + .unwrap_or(false); + if !on_path { + eprintln!("⚠️ {} is not on PATH", bin_dir.display()); + } + if skipped > 0 { + std::process::exit(1); + } +} + +/// Best-effort shutdown of the resident daemon + MCP before installing. +/// +/// `uffs --daemon kill` is given 10 seconds (a wedged daemon must not +/// hang the install), then any survivors are hard-killed by image name — +/// `pkill -x` on Unix, `taskkill /IM … /F` on Windows. Every step is +/// best-effort: on a machine with nothing running (or no `uffs` on PATH +/// yet), all of this silently no-ops. +fn stop_running_services() { + eprintln!(); + eprintln!("🔪 Stopping daemon + MCP (best effort)..."); + if let Ok(mut child) = Command::new("uffs") + .args(["--daemon", "kill"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(100)); + } + _ => { + let _ = child.kill(); + break; + } + } + } + } + for name in ["uffsd", "uffsmcp"] { + let status = if cfg!(windows) { + Command::new("taskkill") + .args(["/IM", &format!("{name}.exe"), "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + } else { + Command::new("pkill") + .args(["-x", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + }; + let _ = status; + } +} + +/// Every binary the workspace build produced, as absolute paths. +/// +/// Re-runs the same build with `--message-format=json` — instant, since +/// the real build already populated the cache — and collects each +/// `compiler-artifact` message's non-null `executable` path. This is the +/// authoritative "what did we build" list: no name guessing, no `.exe` +/// handling, no missed target when a new `[[bin]]` is added. +fn workspace_executables(build_args: &[&str]) -> Vec { + let mut args: Vec<&str> = build_args.to_vec(); + args.push("--message-format=json"); + let output = Command::new("cargo") + .args(&args) + .stderr(Stdio::inherit()) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + let Ok(text) = String::from_utf8(output.stdout) else { + return Vec::new(); + }; + let mut paths = Vec::new(); + for line in text.lines() { + if let Some(exe) = json_executable(line) { + paths.push(PathBuf::from(exe)); + } + } + paths.sort(); + paths.dedup(); + paths +} + +/// The non-null `"executable"` path from one cargo JSON message line, if +/// present. `"executable":null` (a library artifact) yields `None`. +fn json_executable(line: &str) -> Option { + let key = "\"executable\":"; + let after = &line[line.find(key)? + key.len()..]; + // Value is either `null` or `""`. + let rest = after.strip_prefix('"')?; + let end = rest.find('"')?; + Some(unescape_json_path(&rest[..end])) +} + +/// Undo JSON string escaping in a path (`\\` → `\`, `\/` → `/`). +fn unescape_json_path(escaped: &str) -> String { + let mut out = String::with_capacity(escaped.len()); + let mut chars = escaped.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(ch); + } + } + out +}