diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 6812a418..0f9402dd 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -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 | @@ -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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 9d516ed4..cdda867e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "anyhow", "async-stream", "console", + "criterion", "futures-util", "mime_guess", "notify", diff --git a/DESIGN.md b/DESIGN.md index 53aa4ceb..cdd0b008 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/crates/webui-dev-server/Cargo.toml b/crates/webui-dev-server/Cargo.toml index cc67d096..baef29e7 100644 --- a/crates/webui-dev-server/Cargo.toml +++ b/crates/webui-dev-server/Cargo.toml @@ -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 diff --git a/crates/webui-dev-server/benches/watch_hash_bench.rs b/crates/webui-dev-server/benches/watch_hash_bench.rs new file mode 100644 index 00000000..9e37b30b --- /dev/null +++ b/crates/webui-dev-server/benches/watch_hash_bench.rs @@ -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 = (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); diff --git a/crates/webui-dev-server/src/watch.rs b/crates/webui-dev-server/src/watch.rs index 49810c8f..032916bb 100644 --- a/crates/webui-dev-server/src/watch.rs +++ b/crates/webui-dev-server/src/watch.rs @@ -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; @@ -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 @@ -146,6 +153,7 @@ where .collect(); let mut content_hashes: HashMap = 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); @@ -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); } @@ -311,19 +325,17 @@ pub fn default_ignore_paths() -> Vec { ] } -/// 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, path: &Path) -> bool { - match hash_file(path) { +fn content_changed( + cache: &mut HashMap, + 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, @@ -339,22 +351,9 @@ fn should_forward_path( cache: &mut HashMap, 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 { - 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)] @@ -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] @@ -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(()) } } diff --git a/crates/webui-dev-server/src/watch_hash.rs b/crates/webui-dev-server/src/watch_hash.rs new file mode 100644 index 00000000..87dbecfe --- /dev/null +++ b/crates/webui-dev-server/src/watch_hash.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::collections::hash_map::DefaultHasher; +use std::fs::File; +use std::hash::Hasher; +use std::io::{self, Read}; +use std::path::Path; + +pub(super) const HASH_BUFFER_SIZE: usize = 8 * 1024; + +// Largest file hashed for no-op detection; larger inputs always trigger a rebuild. +pub(super) const MAX_HASH_BYTES: usize = 8 * 1024 * 1024; + +// Hash readable regular files with the standard hasher; failures count as changes. +pub(super) fn hash_file(path: &Path, buffer: &mut [u8; HASH_BUFFER_SIZE]) -> Option { + // Reject non-files before opening: opening a FIFO can block the watcher. + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES as u64 { + return None; + } + let mut file = File::open(path).ok()?; + let metadata = file.metadata().ok()?; + if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES as u64 { + return None; + } + hash_contents(&mut file, buffer).ok().flatten() +} + +pub(super) fn hash_contents( + reader: &mut impl Read, + buffer: &mut [u8; HASH_BUFFER_SIZE], +) -> io::Result> { + let mut hasher = DefaultHasher::new(); + let mut remaining = MAX_HASH_BYTES; + loop { + // Probe one byte past the cap to catch growth after the metadata check. + let limit = buffer.len().min(remaining + 1); + match reader.read(&mut buffer[..limit]) { + Ok(0) => return Ok(Some(hasher.finish())), + Ok(count) if count > remaining => return Ok(None), + Ok(count) => { + remaining -= count; + hasher.write(&buffer[..count]); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} diff --git a/crates/webui-dev-server/src/watch_hash_tests.rs b/crates/webui-dev-server/src/watch_hash_tests.rs new file mode 100644 index 00000000..6c34a9de --- /dev/null +++ b/crates/webui-dev-server/src/watch_hash_tests.rs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::collections::hash_map::DefaultHasher; +use std::fs::File; +use std::hash::Hasher; +use std::io::{self, Cursor, Read, Seek, SeekFrom, Write}; + +use super::hash::*; + +struct ShortReads<'a> { + contents: Cursor<&'a [u8]>, + chunks: &'a [usize], + next: usize, + interrupt: bool, +} + +impl Read for ShortReads<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + assert!(!buffer.is_empty()); + assert!(buffer.len() <= HASH_BUFFER_SIZE); + self.interrupt = !self.interrupt; + if self.interrupt { + return Err(io::ErrorKind::Interrupted.into()); + } + let count = buffer.len().min(self.chunks[self.next % self.chunks.len()]); + self.next += 1; + self.contents.read(&mut buffer[..count]) + } +} + +fn expected_hash(contents: &[u8]) -> u64 { + let mut hasher = DefaultHasher::new(); + hasher.write(contents); + hasher.finish() +} + +#[test] +fn digest_matches_whole_file_with_varied_read_boundaries_and_reused_buffer() -> io::Result<()> { + let mut buffer = [0xff; HASH_BUFFER_SIZE]; + for size in [0, 1, 7, 8, 9, 8191, 8192, 8193, 3 * 8192 + 37, 0, 3] { + let contents: Vec = (0_u8..=255).cycle().take(size).collect(); + for chunks in [ + &[1][..], + &[7][..], + &[8][..], + &[9][..], + &[8192][..], + &[3, 8191, 2, 37, 8][..], + ] { + let mut reader = ShortReads { + contents: Cursor::new(contents.as_slice()), + chunks, + next: 0, + interrupt: false, + }; + assert_eq!( + hash_contents(&mut reader, &mut buffer)?, + Some(expected_hash(&contents)), + "size={size}, chunks={chunks:?}" + ); + } + } + Ok(()) +} + +struct FailingRead<'a> { + contents: &'a [u8], + error: io::ErrorKind, +} + +impl Read for FailingRead<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.contents.is_empty() { + return Err(self.error.into()); + } + self.contents.read(buffer) + } +} + +#[test] +fn read_errors_do_not_return_partial_hashes_or_poison_reuse() -> io::Result<()> { + let mut buffer = [0_u8; HASH_BUFFER_SIZE]; + for kind in [ + io::ErrorKind::PermissionDenied, + io::ErrorKind::NotFound, + io::ErrorKind::UnexpectedEof, + io::ErrorKind::WouldBlock, + io::ErrorKind::Other, + ] { + for contents in [&b""[..], &b"partial contents"[..]] { + let mut reader = FailingRead { + contents, + error: kind, + }; + assert!(matches!( + hash_contents(&mut reader, &mut buffer), + Err(error) if error.kind() == kind + )); + assert_eq!( + hash_contents(&mut &b"next file"[..], &mut buffer)?, + Some(expected_hash(b"next file")) + ); + } + } + Ok(()) +} + +#[test] +fn cap_is_inclusive_and_reads_at_most_one_extra_byte() -> io::Result<()> { + let mut buffer = [0_u8; HASH_BUFFER_SIZE]; + let contents = vec![b'x'; MAX_HASH_BYTES + HASH_BUFFER_SIZE]; + for size in [ + MAX_HASH_BYTES - 1, + MAX_HASH_BYTES, + MAX_HASH_BYTES + 1, + contents.len(), + ] { + let mut reader = Cursor::new(&contents[..size]); + let result = hash_contents(&mut reader, &mut buffer)?; + if size <= MAX_HASH_BYTES { + assert_eq!(result, Some(expected_hash(&contents[..size]))); + } else { + assert_eq!(result, None); + } + assert_eq!(reader.position(), size.min(MAX_HASH_BYTES + 1) as u64); + } + Ok(()) +} + +struct GrowOnRead { + reader: File, + append_to: File, + appended: bool, +} + +impl Read for GrowOnRead { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.appended { + self.append_to.write_all(b"!")?; + self.appended = true; + } + self.reader.read(buffer) + } +} + +#[test] +fn growth_after_metadata_is_rejected_and_does_not_poison_reuse() -> io::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("growing.css"); + let file = File::create(&path)?; + file.set_len(MAX_HASH_BYTES as u64)?; + drop(file); + + let reader = File::open(&path)?; + assert_eq!(reader.metadata()?.len(), MAX_HASH_BYTES as u64); + let mut reader = GrowOnRead { + reader, + append_to: std::fs::OpenOptions::new().append(true).open(&path)?, + appended: false, + }; + let mut buffer = [0_u8; HASH_BUFFER_SIZE]; + assert_eq!(hash_contents(&mut reader, &mut buffer)?, None); + assert_eq!(reader.reader.stream_position()?, MAX_HASH_BYTES as u64 + 1); + assert_eq!( + hash_contents(&mut &b"after growth"[..], &mut buffer)?, + Some(expected_hash(b"after growth")) + ); + Ok(()) +} + +#[test] +fn file_hash_covers_empty_multibuffer_and_cap_sized_files() -> io::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("input.css"); + let mut buffer = [0_u8; HASH_BUFFER_SIZE]; + for size in [ + 0, + 3 * HASH_BUFFER_SIZE + 7, + MAX_HASH_BYTES - 1, + MAX_HASH_BYTES, + ] { + let mut contents = vec![b'x'; size]; + if let Some(last) = contents.last_mut() { + *last = b'y'; + } + std::fs::write(&path, &contents)?; + assert_eq!( + hash_file(&path, &mut buffer), + Some(expected_hash(&contents)) + ); + if size > 0 { + let mut file = std::fs::OpenOptions::new().write(true).open(&path)?; + file.seek(SeekFrom::End(-1))?; + file.write_all(b"!")?; + assert_ne!( + hash_file(&path, &mut buffer), + Some(expected_hash(&contents)) + ); + } + } + Ok(()) +} + +#[test] +fn missing_non_file_and_oversized_inputs_have_no_digest() -> io::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("input.css"); + let mut buffer = [0_u8; HASH_BUFFER_SIZE]; + assert_eq!(hash_file(&path, &mut buffer), None); + assert_eq!(hash_file(root.path(), &mut buffer), None); + File::create(&path)?.set_len(MAX_HASH_BYTES as u64 + 1)?; + assert_eq!(hash_file(&path, &mut buffer), None); + + std::fs::write(&path, b"readable again")?; + assert_eq!( + hash_file(&path, &mut buffer), + Some(expected_hash(b"readable again")) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn non_regular_socket_is_rejected() -> io::Result<()> { + let root = tempfile::tempdir()?; + let path = root.path().join("socket"); + let _listener = std::os::unix::net::UnixListener::bind(&path)?; + assert_eq!(hash_file(&path, &mut [0_u8; HASH_BUFFER_SIZE]), None); + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b3f65802..96c8a7f8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -333,6 +333,7 @@ const CRITERION_BENCHES: &[(&str, &str)] = &[ ("microsoft-webui-expressions", "expressions_bench"), ("microsoft-webui-state", "state_bench"), ("microsoft-webui-ffi", "protocol_bench"), + ("microsoft-webui-dev-server", "watch_hash_bench"), ("microsoft-webui", "contact_book_bench"), ("microsoft-webui", "streaming_bench"), ("microsoft-webui", "component_assets_bench"), @@ -1081,6 +1082,11 @@ mod tests { ); } + #[test] + fn criterion_bench_table_includes_watcher_hashing() { + assert!(CRITERION_BENCHES.contains(&("microsoft-webui-dev-server", "watch_hash_bench"))); + } + #[test] fn criterion_bench_table_targets_exist_on_disk() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))