Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c055225
fix(cli): align the daemon status tables
githubrobbi Aug 12, 2026
2fc73b3
fix(dev): use-local skips unchanged binaries and cycles the broker se…
githubrobbi Aug 12, 2026
9375b46
fix(dev): use-local restarts the daemon it stopped
githubrobbi Aug 12, 2026
f21d4ba
docs(daemon): document the memory tiers and that Hot is preload-only
githubrobbi Aug 12, 2026
3985462
fix(dev): use-local stops the MCP cleanly and restarts it
githubrobbi Aug 12, 2026
c19e80d
feat(watchdog): user-level supervisor for the resident daemon + MCP
githubrobbi Aug 12, 2026
5b25c3e
feat(watchdog): wire supervision into the service lifecycle
githubrobbi Aug 12, 2026
d62722c
fix(cli): align the per-drive component breakdown
githubrobbi Aug 12, 2026
5005228
fix(cli): align the physical-drive table past the free-space column
githubrobbi Aug 12, 2026
a1ab339
fix(watchdog): record stop intent BEFORE the shutdown, not after
githubrobbi Aug 12, 2026
96f165b
fix(watchdog): stop supervisor restarts erasing stop intent; add a log
githubrobbi Aug 13, 2026
5c29849
fix(watchdog): read liveness per service so a stopped daemon stays st…
githubrobbi Aug 13, 2026
31b8ccc
docs(daemon): document residency, the watchdog, and the drive views
githubrobbi Aug 13, 2026
c6e1904
docs: repair broken intra-doc anchors across the manual and README
githubrobbi Aug 13, 2026
627752d
fix(core): bound column growth so one new file cannot double the index
githubrobbi Aug 13, 2026
7741ba3
fix(aggregation): honour every search filter, not four of them
githubrobbi Aug 13, 2026
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
9 changes: 9 additions & 0 deletions Cargo.lock

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

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ members = [
"crates/uffs-broker", # 🔑 Windows elevated handle broker (optional)
"crates/uffs-vss-requestor", # 🩹 Per-run native VSS snapshot helper, spawned by uffs-broker (optional)
# ── Surfaces ──
"crates/uffs-cli", # 🖥️ Command-line interface
"crates/uffs-update", # ⬆️ Self-update acquire helper (HTTP/TLS isolated from the CLI)
"crates/uffs-cli", # 🖥️ Command-line interface
"crates/uffs-update", # ⬆️ Self-update acquire helper (HTTP/TLS isolated from the CLI)
"crates/uffs-watchdog", # 🐕 User-level supervisor for the resident daemon + MCP
# NOTE: uffs-tui and uffs-gui have moved to the private uffs-products repo.
# ── Tools ──
"crates/uffs-diag", # 🔬 Retained workspace-only diagnostic tools (not shipped in dist/)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Measured 2026-06-11 on AMD Ryzen 9 3900XT, 64 GB RAM, Windows 11 Pro 24H2 — cr
| **HOT (`*` top-100)** | Full-scan across all drives with `--limit 100` | **1 112 ms** e2e¹ | 27 ms |
| **HOT (targeted)** | `notepad.exe` / `win*` / `*.dll` / `config` etc. | **29–32 ms** CLI e2e | 9–10 ms |

¹ The `*` top-100 path regressed from the v0.5.4 163 ms figure after the Phase 2 sort rewrite ([raw log](docs/benchmarks/raw/2026-04-v0.5.66_full-benchmark-suite.txt), n=30, StdDev 21 ms); daemon-side is 1 081 ms — the CLI tax is negligible here. Tracked in the [archived April report](docs/benchmarks/archive/2026-04-v0.5.66-vs-everything-and-cpp.md#known-regressions).
¹ The `*` top-100 path regressed from the v0.5.4 163 ms figure after the Phase 2 sort rewrite ([raw log](docs/benchmarks/raw/2026-04-v0.5.66_full-benchmark-suite.txt), n=30, StdDev 21 ms); daemon-side is 1 081 ms — the CLI tax is negligible here. Tracked in the [archived April report](docs/benchmarks/archive/2026-04-v0.5.66-vs-everything-and-cpp.md#known-regressions-published-because-trust--hype).

**Scale ceiling:** **100.4 M records** tested with offline MFT clones (v0.5.4 capture, not re-verified since) — targeted queries stayed at 11–13 ms e2e.

Expand Down
36 changes: 34 additions & 2 deletions crates/uffs-cli/src/commands/daemon_mgmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,17 @@ fn daemon_start(
);
}

// An explicit OPERATOR start revokes any earlier stop intent, so the
// watchdog resumes supervising this service.
//
// A supervisor-driven restart must NOT clear it: the watchdog
// respawns by invoking this very command, so clearing here would let
// it erase the marker it is supposed to obey — the intent survives
// exactly one tick and the service bounces back anyway. The watchdog
// sets `UFFS_SUPERVISED_RESTART` to say "this start is mine".
if std::env::var_os("UFFS_SUPERVISED_RESTART").is_none() {
uffs_client::daemon_ctl::clear_stop_intent(uffs_client::daemon_ctl::ServiceKind::Daemon);
}
if !is_quiet() {
println!("Starting daemon...");
}
Expand Down Expand Up @@ -410,9 +421,27 @@ fn daemon_start(
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn daemon_stop() -> Result<()> {
if let Ok(mut client) = UffsClientSync::connect_raw() {
client
// Record the intent BEFORE the RPC, not after.
//
// `shutdown()` blocks until the daemon is actually gone, and on a
// large index (24.9 M records, seven journal loops) that teardown
// takes seconds. Writing the marker afterwards leaves a window in
// which the daemon is already dead and the marker does not exist
// yet — a watchdog tick landing there sees an unexplained death
// and dutifully respawns it, so a deliberate stop bounces back.
// Observed exactly that on a live box before this ordering fix.
uffs_client::daemon_ctl::record_stop_intent(uffs_client::daemon_ctl::ServiceKind::Daemon);
if let Err(err) = client
.shutdown()
.with_context(|| "Shutdown RPC failed — try `uffs --daemon kill` instead")?;
.with_context(|| "Shutdown RPC failed — try `uffs --daemon kill` instead")
{
// The daemon is still up: an intent we never carried out must
// not keep the watchdog from reviving a later genuine crash.
uffs_client::daemon_ctl::clear_stop_intent(
uffs_client::daemon_ctl::ServiceKind::Daemon,
);
return Err(err);
}
println!("Daemon shutdown requested.");
} else {
println!("Daemon is not running.");
Expand All @@ -430,6 +459,9 @@ fn daemon_stop() -> Result<()> {
/// running" half-kill.
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn daemon_kill() -> Result<()> {
// A kill is as deliberate as a stop — same reasoning, same ordering:
// record before the process actually dies.
uffs_client::daemon_ctl::record_stop_intent(uffs_client::daemon_ctl::ServiceKind::Daemon);
let pid_path = pid_file_path();

let mut pid =
Expand Down
53 changes: 47 additions & 6 deletions crates/uffs-cli/src/commands/daemon_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ use uffs_statusfmt::{Glyph, Palette, field, header, section, status_row};
/// One mebibyte, for the `bytes → MB` display conversions.
const MIB: u64 = 1024 * 1024;

/// Width reserved for a quoted volume label in the physical-drive table,
/// so the `· indexed (…)` column after it lines up across rows.
const LABEL_COLUMN: usize = 12;

/// `uffs --daemon status [-v] [--json]` — show daemon status, PID, drives, and
/// (in long / JSON form) performance counters.
///
Expand Down Expand Up @@ -387,9 +391,26 @@ fn print_drive_line(palette: Palette, dr: &DriveInfo, memory: &[DriveMemoryInfo]
match memory.iter().find(|dm| dm.drive == dr.letter) {
Some(dm) => {
let mb = |bytes: u64| bytes / MIB;
// Every numeric column is width-padded so the whole
// block reads as a table down the list. Unpadded, a
// one-digit `rec=1` and a three-digit `rec=608` started
// in the same place and pushed every later field out of
// line, which is exactly what you cannot scan by eye:
//
// [rec=1 names=0 tri=0 ch=0 ext=0]
// [rec=608 names=439 tri=518 ch=55 ext=27]
//
// Widths: rec/names/tri hold four digits (a ~10 GB
// component on a very large drive), ch/ext three. The
// source label is padded too — it is `live` today but
// `cache` and friends exist, and an unpadded label would
// shift the whole rest of the row.
// Pad the whole `(source)` token, not the text inside
// it — `(live )` with the space before the paren reads
// as a typo.
let source = format!("({})", dr.source);
println!(
" {glyph} {letter} {records:>12} records ({}) \u{b7} {} MB [rec={} names={} tri={} ch={} ext={}]",
dr.source,
" {glyph} {letter} {records:>12} records {source:<7} \u{b7} {:>6} MB [rec={:>4} names={:>4} tri={:>4} ch={:>3} ext={:>3}]",
mb(dm.heap_bytes),
mb(dm.records_bytes),
mb(dm.names_bytes),
Expand Down Expand Up @@ -448,7 +469,12 @@ fn print_physical_drive_line(palette: Palette, drive: &PhysicalDrive, loaded: &[
use uffs_client::format::{format_bytes, format_number_commas};

let boot = if drive.is_boot { "*" } else { "" };
let letter = palette.bold(&format!("{}:{boot}", drive.letter));
// Pad the RAW text before colouring — ANSI escapes would be counted by a
// width specifier applied afterwards and silently break the alignment
// (same rule as `uffs_statusfmt::field`). The boot marker makes `C:*`
// one column wider than `D:`, which shifted every column on the boot
// drive's row relative to the others.
let letter = palette.bold(&format!("{:<3}", format!("{}:{boot}", drive.letter)));
let (glyph, index_note) = loaded
.iter()
.find(|info| info.letter == drive.letter)
Expand All @@ -458,16 +484,31 @@ fn print_physical_drive_line(palette: Palette, drive: &PhysicalDrive, loaded: &[
(
Glyph::Up,
format!(
" \u{b7} indexed ({} records)",
" \u{b7} indexed ({:>11} records)",
format_number_commas(info.records as u64)
),
)
},
);
// Pad the volume label so the `· indexed (…)` column that follows
// starts in the same place on every row. Unpadded, a short label
// ("DATA") and a long one ("NTFS_16_GB") pushed the index note to
// different columns, which is the part you scan down the list.
//
// 12 columns fits the labels seen in practice (plus the quotes);
// NTFS permits up to 32, and a longer one simply pushes its own row
// rather than being truncated — losing information to preserve a
// column would be the wrong trade.
let label = if drive.label.is_empty() {
String::new()
// Still occupy the column, so a drive with no label does not
// pull its index note left of everyone else's.
" ".repeat(LABEL_COLUMN + 2)
} else {
format!(" \u{201c}{}\u{201d}", drive.label)
format!(
" {:<width$}",
format!("\u{201c}{}\u{201d}", drive.label),
width = LABEL_COLUMN
)
};
println!(
" {} {letter} {:<9} {:>9} \u{b7} {:>4.0}% used \u{b7} {:>9} free{label}{index_note}",
Expand Down
26 changes: 19 additions & 7 deletions crates/uffs-cli/src/commands/daemon_tiering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,17 +344,29 @@ fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
if bytes >= GIB {
// Fixed 10-column cell: a 6-wide RIGHT-aligned magnitude then a
// 3-wide left-aligned unit, so every row lines up on the decimal
// point rather than ragging left off the unit:
//
// 1.070 GiB
// 509 MiB
// 2 MiB
//
// Left-aligning the whole cell (the previous behaviour) put `2 MiB`
// and `1.07 GiB` at the same start column, which reads as noise in
// a column you scan vertically to compare sizes.
let (magnitude, unit) = if bytes >= GIB {
let whole = bytes / GIB;
let hundredths = (bytes % GIB).saturating_mul(100) / GIB;
format!("{whole}.{hundredths:02} GiB")
let thousandths = (bytes % GIB).saturating_mul(1000) / GIB;
(format!("{whole}.{thousandths:03}"), "GiB")
} else if bytes >= MIB {
format!("{} MiB", bytes / MIB)
((bytes / MIB).to_string(), "MiB")
} else if bytes >= KIB {
format!("{} KiB", bytes / KIB)
((bytes / KIB).to_string(), "KiB")
} else {
format!("{bytes} B")
}
(bytes.to_string(), "B")
};
format!("{magnitude:>6} {unit:<3}")
}

/// Format a Unix-millisecond timestamp as a human-readable elapsed string
Expand Down
111 changes: 111 additions & 0 deletions crates/uffs-cli/src/commands/resident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fn resident_on(
let argv = daemon_argv(mft_files, data_dir, drives);
platform::turn_on(&exe, &argv)?;
write_marker(&argv)?;
arm_watchdog();
println!(
"\nUFFS is now resident: uffsd starts at login with --no-retire\n\
(never exits on idle; memory tiering still parks unused drives),\n\
Expand All @@ -98,6 +99,95 @@ fn resident_on(
Ok(())
}

/// Start the user-level watchdog that keeps the resident services up.
///
/// Windows only, and deliberately so: launchd (`KeepAlive`) and systemd
/// (`Restart=on-failure`) already supervise the daemon on macOS and
/// Linux, so a second supervisor there would be redundant machinery
/// racing the OS. The Windows `Run` key fires once at login and never
/// again, which is precisely the gap `uffs-watchdog` fills.
///
/// Best-effort: residency is still installed and useful without it.
#[cfg(windows)]
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn arm_watchdog() {
let Some(exe) = watchdog_exe() else {
println!("(watchdog binary not found next to uffs — skipping supervision)");
return;
};
// Already supervising? Starting a second one would double every
// respawn decision.
if watchdog_running() {
return;
}
let spawned = std::process::Command::new(&exe)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok();
if spawned {
println!("Watchdog armed: crashed services are restarted automatically.");
} else {
println!(
"(could not start the watchdog — run {} manually)",
exe.display()
);
}
}

/// Non-Windows: launchd / systemd already supervise the daemon.
#[cfg(not(windows))]
const fn arm_watchdog() {}

/// Stop the watchdog that [`arm_watchdog`] started.
///
/// `resident on` arms supervision, so `resident off` must disarm it:
/// leaving a supervisor running after residency is switched off means
/// the very next `uffs --daemon stop` gets second-guessed by a process
/// the user believes they just removed. The daemon itself is left
/// alone (as `resident off` already reports) — this only withdraws the
/// supervision, not the service.
#[cfg(windows)]
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn disarm_watchdog() {
if !watchdog_running() {
return;
}
let stopped = std::process::Command::new("taskkill")
.args(["/IM", "uffs-watchdog.exe", "/F"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success());
if stopped {
println!("Watchdog disarmed: services are no longer restarted automatically.");
} else {
println!("(could not stop the watchdog — end uffs-watchdog.exe manually)");
}
}

/// Non-Windows: nothing was armed, so nothing to disarm.
#[cfg(not(windows))]
const fn disarm_watchdog() {}

/// Locate `uffs-watchdog` beside the running `uffs`.
#[cfg(windows)]
fn watchdog_exe() -> Option<PathBuf> {
let here = std::env::current_exe().ok()?;
let candidate = here.parent()?.join("uffs-watchdog.exe");
candidate.is_file().then_some(candidate)
}

/// Is a watchdog already running for this user?
#[cfg(windows)]
fn watchdog_running() -> bool {
std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq uffs-watchdog.exe", "/NH"])
.output()
.is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains("uffs-watchdog.exe"))
}

/// Write the resident marker (`resident.args`) so implicit auto-spawns
/// — the next search after a crash or a manual stop — revive the
/// daemon with the same resident argv the login item uses (merged in
Expand Down Expand Up @@ -195,6 +285,7 @@ fn resident_off() -> Result<()> {
platform::turn_off()?;
// Auto-spawns fall back to the default idle-retire lifetime.
let _absent = std::fs::remove_file(uffs_client::daemon_ctl::resident_args_path());
disarm_watchdog();
if daemon_running() {
println!(
"A daemon is still running; it is unaffected.\n\
Expand All @@ -218,13 +309,33 @@ fn resident_status() {
} else {
println!("Auto-spawn: default (idle retire)");
}
print_watchdog_state();
if daemon_running() {
println!("Daemon: running (details: uffs --daemon status)");
} else {
println!("Daemon: not running");
}
}

/// Report whether supervision is currently armed.
///
/// Without this line the watchdog is invisible: `resident status` would
/// claim residency is off while a supervisor kept restarting services.
#[cfg(windows)]
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn print_watchdog_state() {
if watchdog_running() {
println!("Watchdog: supervising (crashed services are restarted)");
} else {
println!("Watchdog: not running");
}
}

/// Non-Windows: launchd / systemd supervise, so there is no watchdog to
/// report on.
#[cfg(not(windows))]
const fn print_watchdog_state() {}

// ── shared plumbing ─────────────────────────────────────────────────

/// Run one system tool to completion, mapping a non-zero exit into an
Expand Down
Loading
Loading