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
18 changes: 17 additions & 1 deletion BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ WebUI itself.

| Bench | Layer | Wall time | What it measures | Use when |
|---|---|---|---|---|
| `cargo xtask bench all` | criterion micro | ~5 min | per-fn wall-clock for parser, handler, protocol, expressions, state, webui (incl. streaming + contact-book) | full snapshot of every micro-bench |
| `cargo xtask bench all` | criterion micro | ~5 min | per-fn wall-clock for parser, handler, protocol, expressions, state, watcher hashing, webui (incl. streaming + contact-book) | full snapshot of every micro-bench |
| `cargo xtask bench streaming` | criterion micro | ~60 s | writer-path wall-clock + first-chunk TTFB | inner-loop iteration on the streaming module |
| `cargo xtask bench contact-book` | criterion micro | ~90 s | end-to-end render at 10/100/1000 contacts | inner-loop iteration on handler/state/expressions |
| `cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench` | criterion micro | ~20 s | small/large file hashing and event bursts with reused scratch | watcher hashing CPU and I/O tradeoffs |
| `cargo xtask bench node-addon` | Node/N-API | ~15 s after build | `Protocol` construction, buffered render, first callback, total stream time | changes to `webui-node` or the public Node wrapper |
| `cargo xtask bench streaming-resource` | example | ~30 s | exact alloc count + bytes + getrusage CPU + RSS | proving zero-alloc claims; allocation regression hunting |
| `cargo xtask bench streaming-e2e-ttfb` | example | ~10 s | HTTP-level TTFB / TTLB through actix | confirming wire-level streaming win |
Expand Down Expand Up @@ -126,6 +127,8 @@ Standard criterion harnesses. Each crate has its own `benches/` dir:
* `crates/webui-state/benches/state_bench.rs`
* `crates/webui/benches/contact_book_bench.rs`: end-to-end render
* `crates/webui/benches/streaming_bench.rs`: writer-path wall-clock + TTFB
* `crates/webui-dev-server/benches/watch_hash_bench.rs`: file hashing and
32-file bursts with one reusable scratch buffer
* `crates/webui/benches/component_assets_bench.rs`: static asset graph rendering
* `crates/webui/benches/server_request_bench.rs`: router-aware full HTML and
JSON requests, including sparse projection from a large parsed state tree
Expand All @@ -136,6 +139,19 @@ These integrate with criterion's HTML reports
passes those flags through so you don't need to remember `cargo
bench` invocation details.

The watcher hashing benchmark includes opening and metadata, but creates its
fixtures outside timing. Run the same harness against both implementations:

```bash
cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench -- --save-baseline before
cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench -- --baseline before
```

Report small-file bursts as well as large files: bounded reads can trade extra
I/O calls for lower content-buffer allocation. Distinguish Criterion's printed
time estimates from extracted median estimates, and source-derived buffer
bounds from measured process RSS.

### `streaming-resource` (counting allocator + getrusage)

`crates/webui/examples/streaming_resource_bench.rs` installs a custom
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

12 changes: 10 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2307,8 +2307,16 @@ changed file and drops events whose bytes are unchanged, so a no-op save
(repeated Ctrl+S that rewrites identical content) triggers no rebuild in the
clean state. While a rebuild error is active, unchanged events are forwarded so a
no-op save can retry transient failures without forcing a real content edit.
Deletions and oversized files always count as changed. Each rebuild's terminal
line names the triggering file (`↻ rebuilt app-shell.css …`, or `… (+N more)`).
Deletions and oversized files always count as changed. Hashing reuses one
8 KiB scratch buffer per watcher instead of allocating a whole-file content
buffer. Regular-file metadata is checked before and after opening, and reads
are bounded to 8 MiB plus one overflow-probe byte so growth after the metadata
check cannot bypass the cap. Short reads preserve the whole-file digest;
interrupted reads retry. Other read failures count as changes and never cache
a partial digest.

Each rebuild's terminal line names the triggering file
(`↻ rebuilt app-shell.css …`, or `… (+N more)`).
Incremental rebuild failures are retained in dev-server state. The rebuild
worker reports the error to the terminal and live-reload SSE; subsequent browser
refreshes, route renders, JSON partial requests, and component template requests
Expand Down
5 changes: 5 additions & 0 deletions crates/webui-dev-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ console = { workspace = true }
time = { workspace = true }

[dev-dependencies]
criterion = { workspace = true }
tempfile = { workspace = true }

[[bench]]
name = "watch_hash_bench"
harness = false
70 changes: 70 additions & 0 deletions crates/webui-dev-server/benches/watch_hash_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! File hashing latency, including opening/metadata, with scratch reused across events.
//! Run: cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench

use std::hint::black_box;
use std::path::PathBuf;
use std::time::Duration;

use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};

#[path = "../src/watch_hash.rs"]
mod watch_hash;

use watch_hash::{hash_file, HASH_BUFFER_SIZE};

fn watch_hash_bench(c: &mut Criterion) {
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target");
std::fs::create_dir_all(&target)
.unwrap_or_else(|error| panic!("Cannot create benchmark fixture directory: {error}"));
let fixtures = tempfile::Builder::new()
.prefix("watch-hash-bench-")
.tempdir_in(target)
.unwrap_or_else(|error| panic!("Cannot create benchmark fixtures: {error}"));
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
let mut group = c.benchmark_group("watch_hash");
group
.sample_size(50)
.warm_up_time(Duration::from_secs(1))
.measurement_time(Duration::from_secs(3));

for size in [1024, 1024 * 1024, 8 * 1024 * 1024] {
let path = fixtures.path().join(format!("file-{size}"));
std::fs::write(&path, vec![b'x'; size])
.unwrap_or_else(|error| panic!("Cannot write benchmark fixture: {error}"));
assert!(hash_file(&path, &mut buffer).is_some());
group.throughput(Throughput::Bytes(size as u64));
group.bench_with_input(BenchmarkId::new("file_bytes", size), &path, |b, path| {
b.iter(|| black_box(hash_file(black_box(path), &mut buffer)));
});
}

for size in [1024, 256 * 1024] {
let paths: Vec<PathBuf> = (0..32)
.map(|index| {
let path = fixtures.path().join(format!("burst-{size}-{index}"));
std::fs::write(&path, vec![b'y'; size])
.unwrap_or_else(|error| panic!("Cannot write burst fixture: {error}"));
path
})
.collect();
group.throughput(Throughput::Bytes(32 * size as u64));
group.bench_with_input(
BenchmarkId::new("burst_32_files", size),
&paths,
|b, paths| {
b.iter(|| {
for path in paths {
black_box(hash_file(black_box(path), &mut buffer));
}
});
},
);
}
group.finish();
}

criterion_group!(benches, watch_hash_bench);
criterion_main!(benches);
153 changes: 116 additions & 37 deletions crates/webui-dev-server/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
//! background thread; **the handle must be kept alive** for the watcher
//! to run.

use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::Hasher;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
Expand All @@ -24,6 +22,15 @@ use notify_debouncer_mini::{
new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, Debouncer,
};

#[path = "watch_hash.rs"]
mod hash;

#[cfg(test)]
#[path = "watch_hash_tests.rs"]
mod hash_tests;

use hash::{hash_file, HASH_BUFFER_SIZE};

/// Owns the watcher background thread. Drop to stop watching.
///
/// `notify-debouncer-mini` spawns its background thread inside the
Expand Down Expand Up @@ -146,6 +153,7 @@ where
.collect();

let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
let retry_unchanged_when = cfg.retry_unchanged_when.clone();
let explicit_filter = explicit_files.clone();
let notify_config = notify::Config::default().with_follow_symlinks(false);
Expand Down Expand Up @@ -183,8 +191,14 @@ where
let retry_unchanged = retry_unchanged_when
.as_ref()
.is_some_and(|predicate| predicate());
paths
.retain(|path| should_forward_path(&mut content_hashes, path, retry_unchanged));
paths.retain(|path| {
should_forward_path(
&mut content_hashes,
path,
retry_unchanged,
&mut hash_buffer,
)
});
if !paths.is_empty() {
on_event(paths);
}
Expand Down Expand Up @@ -311,19 +325,17 @@ pub fn default_ignore_paths() -> Vec<PathBuf> {
]
}

/// Largest file the watcher will hash to detect a no-op change. Above this,
/// an event is always treated as a change — hashing a huge file on every event
/// would cost more than an occasional rebuild. Dev source files are tiny, so
/// this only guards pathological inputs.
const MAX_HASH_BYTES: u64 = 8 * 1024 * 1024;

/// Whether `path`'s content changed since the previous event, updating `cache`.
///
/// A path that cannot be read as a regular file within the size cap (deleted,
/// a directory, a permissions error, or oversized) is treated as **changed** so
/// deletions still trigger a rebuild and large files are never silently skipped.
fn content_changed(cache: &mut HashMap<PathBuf, u64>, path: &Path) -> bool {
match hash_file(path) {
fn content_changed(
cache: &mut HashMap<PathBuf, u64>,
path: &Path,
buffer: &mut [u8; HASH_BUFFER_SIZE],
) -> bool {
match hash_file(path, buffer) {
Some(hash) => match cache.insert(path.to_path_buf(), hash) {
Some(previous) => previous != hash,
None => true,
Expand All @@ -339,22 +351,9 @@ fn should_forward_path(
cache: &mut HashMap<PathBuf, u64>,
path: &Path,
retry_unchanged: bool,
buffer: &mut [u8; HASH_BUFFER_SIZE],
) -> bool {
content_changed(cache, path) || retry_unchanged
}

/// Hash the full contents of `path`, or `None` if it is not a readable regular
/// file within [`MAX_HASH_BYTES`]. Uses the standard hasher — collision
/// resistance is irrelevant here; we only need "did these bytes change".
fn hash_file(path: &Path) -> Option<u64> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES {
return None;
}
let bytes = std::fs::read(path).ok()?;
let mut hasher = DefaultHasher::new();
hasher.write(&bytes);
Some(hasher.finish())
content_changed(cache, path, buffer) || retry_unchanged
}

#[cfg(test)]
Expand Down Expand Up @@ -489,25 +488,26 @@ mod tests {
let file = dir.path().join("a.css");
std::fs::write(&file, "a { color: red; }").unwrap();
let mut cache = HashMap::new();
let mut buffer = [0_u8; HASH_BUFFER_SIZE];

// First sighting → changed (nothing cached yet).
assert!(content_changed(&mut cache, &file));
assert!(content_changed(&mut cache, &file, &mut buffer));
// Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
assert!(!content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file, &mut buffer));
assert!(!content_changed(&mut cache, &file, &mut buffer));

// A real edit → changed.
std::fs::write(&file, "a { color: blue; }").unwrap();
assert!(content_changed(&mut cache, &file));
assert!(content_changed(&mut cache, &file, &mut buffer));
// Identical again → unchanged.
assert!(!content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file, &mut buffer));

// Deletion → changed, so a rebuild can clear stale output.
std::fs::remove_file(&file).unwrap();
assert!(content_changed(&mut cache, &file));
assert!(content_changed(&mut cache, &file, &mut buffer));
// The cache forgot it, so a later recreation is a fresh change.
std::fs::write(&file, "a { color: blue; }").unwrap();
assert!(content_changed(&mut cache, &file));
assert!(content_changed(&mut cache, &file, &mut buffer));
}

#[test]
Expand All @@ -516,9 +516,88 @@ mod tests {
let file = dir.path().join("a.css");
std::fs::write(&file, "a { color: red; }").unwrap();
let mut cache = HashMap::new();
let mut buffer = [0_u8; HASH_BUFFER_SIZE];

assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));

std::fs::write(&file, "a { color: blue; }").unwrap();
assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
}

#[test]
fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let file = dir.path().join("a.css");
let mut cache = HashMap::new();
let mut buffer = [0_u8; HASH_BUFFER_SIZE];

for replacement in ["deleted", "directory", "oversized"] {
std::fs::write(&file, "original")?;
assert!(content_changed(&mut cache, &file, &mut buffer));
assert!(!content_changed(&mut cache, &file, &mut buffer));

std::fs::remove_file(&file)?;
match replacement {
"directory" => std::fs::create_dir(&file)?,
"oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
_ => {}
}
for _ in 0..2 {
assert!(content_changed(&mut cache, &file, &mut buffer));
assert!(!cache.contains_key(&file));
}
match replacement {
"directory" => std::fs::remove_dir(&file)?,
"oversized" => std::fs::remove_file(&file)?,
_ => {}
}
}
std::fs::write(&file, "original")?;
assert!(content_changed(&mut cache, &file, &mut buffer));
Ok(())
}

assert!(should_forward_path(&mut cache, &file, false));
assert!(!should_forward_path(&mut cache, &file, false));
assert!(should_forward_path(&mut cache, &file, true));
#[test]
fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let paths = [
dir.path().join("large.css"),
dir.path().join("empty.css"),
dir.path().join("small.css"),
];
for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
std::fs::write(path, vec![b'x'; size])?;
}
let mut cache = HashMap::new();
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
for changed in [true, false, false] {
for path in &paths {
assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
}
}
Ok(())
}

#[cfg(unix)]
#[test]
fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let file = dir.path().join("unreadable.css");
std::fs::write(&file, "original")?;
let mut cache = HashMap::new();
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
assert!(content_changed(&mut cache, &file, &mut buffer));
std::fs::remove_file(&file)?;
std::os::unix::fs::symlink("unreadable.css", &file)?;
assert!(content_changed(&mut cache, &file, &mut buffer));
assert!(!cache.contains_key(&file));
std::fs::remove_file(&file)?;
std::fs::write(&file, "original")?;
assert!(content_changed(&mut cache, &file, &mut buffer));
Ok(())
}
}
Loading
Loading