From f6590d4922c656098ad4a9399eb5baa863545efa Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 11 Aug 2026 16:27:01 +0800 Subject: [PATCH] test(perf): publish deterministic bounds Refs #12 --- .github/workflows/ci.yml | 3 + Makefile | 11 +- src/app.rs | 242 ++++++++++++- src/document.rs | 32 ++ src/layout.rs | 23 ++ src/line_index.rs | 11 + src/locator.rs | 11 + src/search.rs | 116 ++++++- src/tui/mod.rs | 709 ++++++++++++++++++++++++++++++++++++++- tests/cli.rs | 100 +++++- 10 files changed, 1252 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 135c923..06042d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - run: cargo fmt --all -- --check - run: cargo clippy --all-targets --all-features --locked -- -D warnings - run: RUSTFLAGS="-C overflow-checks=on" cargo test --release --all-targets --locked + - run: | + RUSTFLAGS="-C overflow-checks=on" make bounded-results > "$RUNNER_TEMP/tut-bounds-v1.ndjson" + cat "$RUNNER_TEMP/tut-bounds-v1.ndjson" - run: make distcheck msrv-ubuntu: diff --git a/Makefile b/Makefile index a70a7a1..0645821 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ MANPAGE = docs/tut.1 PACKAGE_VERSION = $(shell $(CARGO) pkgid --locked 2>/dev/null | sed 's/.*[@:]//') DIST_ARCHIVE = $(CARGO_TARGET_DIR)/package/tut-$(PACKAGE_VERSION).crate -.PHONY: all check release-check installdirs install install-strip installcheck uninstall dist distcheck mostlyclean clean distclean maintainer-clean +.PHONY: all check release-check bounded-results installdirs install install-strip installcheck uninstall dist distcheck mostlyclean clean distclean maintainer-clean all: CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" $(CARGO) build --release --locked $(CARGO_TARGET_OPTION) @@ -35,6 +35,15 @@ check: release-check: CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" $(CARGO) test --release --all-targets --locked $(CARGO_TARGET_OPTION) +bounded-results: + @set -eu; \ + mkdir -p "$(CARGO_TARGET_DIR)"; \ + results=$$(mktemp "$(abspath $(CARGO_TARGET_DIR))/tut-bounds-v1.XXXXXX"); \ + trap 'rm -f "$$results"' EXIT HUP INT TERM; \ + TUT_BOUNDED_RESULTS_FILE="$$results" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" $(CARGO) test --release --locked --lib tui::tests::bounded_results::bounded_results_core $(CARGO_TARGET_OPTION) -- --ignored --exact --test-threads=1 1>&2; \ + TUT_BOUNDED_RESULTS_FILE="$$results" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" $(CARGO) test --release --locked --test cli pty::bounded_results_pty $(CARGO_TARGET_OPTION) -- --ignored --exact --test-threads=1 1>&2; \ + cat "$$results" + installdirs: $(MKDIR_P) "$(DESTDIR)$(bindir)" $(MKDIR_P) "$(DESTDIR)$(man1dir)" diff --git a/src/app.rs b/src/app.rs index 9623454..ece1478 100644 --- a/src/app.rs +++ b/src/app.rs @@ -750,6 +750,48 @@ pub(super) struct App { repeat: Option, #[cfg(test)] fail_search_recall_reserve: bool, + #[cfg(test)] + bounded_counters: BoundedAppCounters, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, Default)] +struct BoundedAppCounters { + max_search_windows: usize, + max_projected_emissions: usize, + max_projected_bytes: usize, + max_grapheme_window_bytes: usize, + render_reserves: usize, + peak_render_bytes: usize, + max_line_step_bytes: usize, + line_step_violation: bool, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy)] +pub(super) struct BoundedCoreMetrics { + pub(super) window_calls: usize, + pub(super) byte_window_calls: usize, + pub(super) grapheme_window_calls: usize, + pub(super) grapheme_window_bytes: usize, + pub(super) utf8_bytes: usize, + pub(super) segmentation_runs: usize, + pub(super) segmentation_bytes: usize, + pub(super) grapheme_emissions: usize, + pub(super) max_search_windows: usize, + pub(super) max_projected_emissions: usize, + pub(super) max_projected_bytes: usize, + pub(super) max_grapheme_window_bytes: usize, + pub(super) render_reserves: usize, + pub(super) highlight_reserves: usize, + pub(super) highlight_comparisons: usize, + pub(super) highlight_outputs: usize, + pub(super) max_highlight_operations: usize, + pub(super) max_highlight_reserves: usize, + pub(super) peak_render_bytes: usize, + pub(super) peak_highlight_bytes: usize, + pub(super) max_line_step_bytes: usize, + pub(super) line_step_violation: bool, } #[cfg(test)] @@ -783,9 +825,84 @@ impl App { repeat: None, #[cfg(test)] fail_search_recall_reserve: false, + #[cfg(test)] + bounded_counters: BoundedAppCounters::default(), + } + } + + #[cfg(test)] + pub(super) fn bounded_core_metrics(&self) -> BoundedCoreMetrics { + let document = self.document_cache.metrics(); + let search = self.search_cache.metrics(); + let sum = |left: usize, right: usize| { + left.checked_add(right) + .expect("bounded metric totals fit usize") + }; + let (highlight_reserves, highlight_comparisons, highlight_outputs, max_ops, max_reserves) = + self.search + .as_ref() + .map_or((0, 0, 0, 0, 0), |search| search.bounded_highlight_metrics()); + let peak_highlight_bytes = self + .search + .as_ref() + .and_then(SearchSession::bounded_highlight_bytes) + .unwrap_or(0); + BoundedCoreMetrics { + window_calls: sum(document.window_calls(), search.window_calls()), + byte_window_calls: sum(document.byte_window_calls(), search.byte_window_calls()), + grapheme_window_calls: sum( + document.grapheme_window_calls(), + search.grapheme_window_calls(), + ), + grapheme_window_bytes: sum( + document.grapheme_window_returned_bytes(), + search.grapheme_window_returned_bytes(), + ), + utf8_bytes: sum( + document.grapheme_utf8_validated_bytes(), + search.grapheme_utf8_validated_bytes(), + ), + segmentation_runs: sum(document.segmentation_runs(), search.segmentation_runs()), + segmentation_bytes: sum( + document.segmentation_advanced_bytes(), + search.segmentation_advanced_bytes(), + ), + grapheme_emissions: sum(document.grapheme_emissions(), search.grapheme_emissions()), + max_search_windows: self.bounded_counters.max_search_windows, + max_projected_emissions: self.bounded_counters.max_projected_emissions, + max_projected_bytes: self.bounded_counters.max_projected_bytes, + max_grapheme_window_bytes: self.bounded_counters.max_grapheme_window_bytes, + render_reserves: self.bounded_counters.render_reserves, + highlight_reserves, + highlight_comparisons, + highlight_outputs, + max_highlight_operations: max_ops, + max_highlight_reserves: max_reserves, + peak_render_bytes: self.bounded_counters.peak_render_bytes, + peak_highlight_bytes, + max_line_step_bytes: self.bounded_counters.max_line_step_bytes, + line_step_violation: self.bounded_counters.line_step_violation, } } + #[cfg(test)] + pub(super) fn bounded_capacity_bytes(&self) -> Option { + let render = self + .current_render_cache() + .map_or(Some(0), |cache| cache.rows.storage().bytes())?; + let search = self + .search + .as_ref() + .map_or(Some(0), SearchSession::bounded_storage_bytes)?; + self.document + .bounded_storage_bytes()? + .checked_add(self.document_cache.bounded_storage_bytes()?)? + .checked_add(self.search_cache.bounded_storage_bytes()?)? + .checked_add(self.row_neighborhood.bounded_storage_bytes()?)? + .checked_add(render)? + .checked_add(search) + } + pub(super) const fn terminal_too_small(&self) -> bool { !self.geometry.is_usable() } @@ -1152,14 +1269,79 @@ impl App { let Some(schedule) = self.background_schedule() else { return Ok(false); }; + #[cfg(test)] + let document_before = self.document_cache.metrics(); + #[cfg(test)] + let search_before = self.search_cache.metrics(); + #[cfg(test)] + let line_before = self.document.line_index_scanned_to(); if let Some(search_turn) = schedule.next_search_turn { self.search_turn = search_turn; } - match schedule.work { + let result = match schedule.work { BackgroundWork::LineIndex => self.advance_line_index(), BackgroundWork::Viewport => self.advance_viewport_locator(), BackgroundWork::Render => self.advance_render(), BackgroundWork::Search => self.advance_search(), + }; + #[cfg(test)] + self.record_bounded_step(schedule.work, document_before, search_before, line_before); + result + } + + #[cfg(test)] + fn record_bounded_step( + &mut self, + work: BackgroundWork, + document_before: crate::document::DocumentMetrics, + search_before: crate::document::DocumentMetrics, + line_before: SourceOffset, + ) { + let document_after = self.document_cache.metrics(); + let search_after = self.search_cache.metrics(); + let delta = |after: usize, before: usize| { + after + .checked_sub(before) + .expect("bounded document metrics are monotonic") + }; + if matches!(work, BackgroundWork::Viewport | BackgroundWork::Render) { + let emissions = delta( + document_after.grapheme_emissions(), + document_before.grapheme_emissions(), + ); + let bytes = delta( + document_after.segmentation_advanced_bytes(), + document_before.segmentation_advanced_bytes(), + ); + let window_bytes = delta( + document_after.grapheme_window_returned_bytes(), + document_before.grapheme_window_returned_bytes(), + ); + self.bounded_counters.max_projected_emissions = + self.bounded_counters.max_projected_emissions.max(emissions); + self.bounded_counters.max_projected_bytes = + self.bounded_counters.max_projected_bytes.max(bytes); + self.bounded_counters.max_grapheme_window_bytes = self + .bounded_counters + .max_grapheme_window_bytes + .max(window_bytes); + } + if work == BackgroundWork::Search { + let windows = delta(search_after.window_calls(), search_before.window_calls()); + self.bounded_counters.max_search_windows = + self.bounded_counters.max_search_windows.max(windows); + } + if work == BackgroundWork::LineIndex { + let line_after = self.document.line_index_scanned_to(); + let advanced = line_after.get().checked_sub(line_before.get()); + let maximum = u64::try_from(crate::document::source_window_max_bytes()) + .expect("source window bounds fit u64"); + let valid = advanced.is_some_and(|bytes| bytes > 0 && bytes <= maximum); + if let Some(bytes) = advanced.and_then(|bytes| usize::try_from(bytes).ok()) { + self.bounded_counters.max_line_step_bytes = + self.bounded_counters.max_line_step_bytes.max(bytes); + } + self.bounded_counters.line_step_violation |= !valid; } } @@ -1790,6 +1972,21 @@ impl App { self.document.validate()?; let rows = sink.finish(); debug_assert_eq!(rows.len(), result.rows); + #[cfg(test)] + { + let reserves = rows.reserve_attempts().total(); + let bytes = rows + .storage() + .bytes() + .expect("bounded render storage fits usize"); + self.bounded_counters.render_reserves = self + .bounded_counters + .render_reserves + .checked_add(reserves) + .expect("bounded render reserve totals fit usize"); + self.bounded_counters.peak_render_bytes = + self.bounded_counters.peak_render_bytes.max(bytes); + } let at_end = result.next.is_none(); let viewport = Viewport { visible_rows: result.rows, @@ -1889,6 +2086,49 @@ impl RenderReserveAttempts { spans: 0, rows: 0, }; + + fn total(self) -> usize { + self.text + .checked_add(self.spans) + .and_then(|total| total.checked_add(self.rows)) + .expect("bounded render reserve totals fit usize") + } +} + +#[cfg(test)] +pub(super) const fn visible_render_memory_budget_bytes() -> usize { + MAX_VISIBLE_RENDER_BYTES +} + +#[cfg(test)] +pub(super) fn visible_render_refusal_is_typed_and_atomic() -> bool { + let mut builder = RenderRowsBuilder::with_limit(1, usize::MAX) + .expect("one render row fits an unbounded test builder"); + let atom = crate::layout::DisplayAtoms::new("x") + .next() + .expect("the refusal probe contains one grapheme") + .project( + DisplayColumn::ZERO, + ContentWidth::new(1).expect("one column is nonzero"), + ) + .expect("the refusal probe grapheme is visible"); + builder.push(atom).expect("the seed atom fits"); + let limit = builder + .storage() + .bytes() + .expect("seed render storage fits usize"); + builder.limit = limit; + let text_len = builder.text.len(); + let spans_len = builder.spans.len(); + let rows_len = builder.rows.len(); + let storage = builder.storage(); + matches!( + builder.push(atom), + Err(TutError::VisibleRenderTooLarge { limit: actual }) if actual == limit + ) && builder.text.len() == text_len + && builder.spans.len() == spans_len + && builder.rows.len() == rows_len + && builder.storage() == storage } impl RenderStorage { diff --git a/src/document.rs b/src/document.rs index e1a416d..24346b6 100644 --- a/src/document.rs +++ b/src/document.rs @@ -79,6 +79,16 @@ impl Document { self.source_start } + #[cfg(test)] + pub(super) const fn line_index_scanned_to(&self) -> SourceOffset { + self.line_index.scanned_to() + } + + #[cfg(test)] + pub(super) fn bounded_storage_bytes(&self) -> Option { + self.line_index.bounded_storage_bytes() + } + pub(super) const fn source_end(&self) -> SourceOffset { self.source_end } @@ -275,6 +285,23 @@ impl DocumentMetrics { } } +#[cfg(test)] +pub(super) const fn max_grapheme_bytes() -> usize { + MAX_GRAPHEME_BYTES +} + +#[cfg(test)] +pub(super) const fn source_window_slop_bytes() -> usize { + UTF8_BOUNDARY_SLOP_BYTES +} + +#[cfg(test)] +pub(super) fn source_window_max_bytes() -> usize { + SOURCE_WINDOW_BYTES + .checked_add(UTF8_BOUNDARY_SLOP_BYTES) + .expect("source window bounds fit usize") +} + impl Default for DocumentCache { fn default() -> Self { Self { @@ -309,6 +336,11 @@ impl DocumentCache { pub(super) const fn metrics(&self) -> DocumentMetrics { self.metrics } + + #[cfg(test)] + pub(super) fn bounded_storage_bytes(&self) -> Option { + self.chunk.capacity().checked_add(self.grapheme.capacity()) + } } pub(super) struct DocumentReader<'a> { diff --git a/src/layout.rs b/src/layout.rs index 437a5f9..684a022 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -19,6 +19,29 @@ const TAB_STOP: u32 = 4; const PROJECTED_SCAN_ATOM_BUDGET: usize = 1024; const PROJECTED_SCAN_BYTE_BUDGET: u64 = SOURCE_WINDOW_BYTES as u64; +#[cfg(test)] +pub(super) const fn projected_scan_atom_budget() -> usize { + PROJECTED_SCAN_ATOM_BUDGET +} + +#[cfg(test)] +pub(super) const fn projected_scan_byte_budget() -> u64 { + PROJECTED_SCAN_BYTE_BUDGET +} + +#[cfg(test)] +pub(super) fn projected_scan_step_byte_bound() -> u64 { + PROJECTED_SCAN_BYTE_BUDGET + .checked_sub(1) + .and_then(|bytes| { + bytes.checked_add( + u64::try_from(crate::document::max_grapheme_bytes()) + .expect("maximum grapheme bytes fit u64"), + ) + }) + .expect("projected scan step bounds fit u64") +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(transparent)] pub(super) struct DisplayColumn(u32); diff --git a/src/line_index.rs b/src/line_index.rs index bac2bac..61fd37b 100644 --- a/src/line_index.rs +++ b/src/line_index.rs @@ -7,6 +7,11 @@ const INITIAL_CHECKPOINT_INTERVAL_BYTES: u64 = 64 * 1024; const INITIAL_CHECKPOINT_INTERVAL_LINES: u64 = 1; const INITIAL_CHECKPOINT_RESERVATION: usize = 1024; +#[cfg(test)] +pub(super) const fn memory_budget_bytes() -> usize { + LINE_INDEX_MEMORY_BUDGET_BYTES +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[repr(transparent)] struct PhysicalLine(u64); @@ -171,6 +176,12 @@ pub(super) struct LineIndex { } impl LineIndex { + #[cfg(test)] + pub(super) fn bounded_storage_bytes(&self) -> Option { + self.checkpoints + .capacity() + .checked_mul(size_of::()) + } pub(super) fn new( source_start: SourceOffset, source_end: SourceOffset, diff --git a/src/locator.rs b/src/locator.rs index 74c9c82..b862e0c 100644 --- a/src/locator.rs +++ b/src/locator.rs @@ -13,6 +13,11 @@ use crate::{ const INITIAL_ROW_NEIGHBORHOOD_CAPACITY: usize = 64; const ROW_NEIGHBORHOOD_CAPACITY: usize = 4096; +#[cfg(test)] +pub(super) const fn row_neighborhood_capacity() -> usize { + ROW_NEIGHBORHOOD_CAPACITY +} + pub(super) fn source_row_bound( source_start: SourceOffset, source_end: SourceOffset, @@ -50,6 +55,12 @@ pub(super) struct RowNeighborhood { } impl RowNeighborhood { + #[cfg(test)] + pub(super) fn bounded_storage_bytes(&self) -> Option { + self.edges + .capacity() + .checked_mul(std::mem::size_of::()) + } pub(super) fn clear(&mut self) { self.key = None; self.edges.clear(); diff --git a/src/search.rs b/src/search.rs index 0304a4b..46f8372 100644 --- a/src/search.rs +++ b/src/search.rs @@ -16,6 +16,26 @@ pub(super) const MAX_SEARCH_QUERY_BYTES: usize = 4096; const MATCH_BLOCK_START_LIMIT: usize = SOURCE_WINDOW_BYTES + MAX_SEARCH_QUERY_BYTES; const DISPLAY_HIGHLIGHT_MERGE_BUDGET: usize = 1024; +#[cfg(test)] +pub(super) const fn search_index_memory_budget_bytes() -> usize { + SEARCH_INDEX_MEMORY_BUDGET_BYTES +} + +#[cfg(test)] +pub(super) const fn search_highlight_memory_budget_bytes() -> usize { + SEARCH_HIGHLIGHT_MEMORY_BUDGET_BYTES +} + +#[cfg(test)] +pub(super) const fn match_block_start_limit() -> usize { + MATCH_BLOCK_START_LIMIT +} + +#[cfg(test)] +pub(super) const fn display_highlight_merge_budget() -> usize { + DISPLAY_HIGHLIGHT_MERGE_BUDGET +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct SearchHighlightKey { columns: u16, @@ -240,6 +260,14 @@ pub(super) struct SearchSession { pending_navigation: i64, highlights: SearchHighlightState, jump_pending: bool, + #[cfg(test)] + highlight_comparisons: usize, + #[cfg(test)] + highlight_outputs: usize, + #[cfg(test)] + max_highlight_operations: usize, + #[cfg(test)] + max_highlight_reserves: usize, } impl SearchSession { @@ -267,6 +295,14 @@ impl SearchSession { pending_navigation: 0, highlights: SearchHighlightState::default(), jump_pending, + #[cfg(test)] + highlight_comparisons: 0, + #[cfg(test)] + highlight_outputs: 0, + #[cfg(test)] + max_highlight_operations: 0, + #[cfg(test)] + max_highlight_reserves: 0, })) } @@ -348,12 +384,38 @@ impl SearchSession { self.highlights = SearchHighlightState::Absent(storage); return Ok(false); } - match job.advance( + #[cfg(test)] + let reserves_before = job.storage.reserve_attempts; + let result = job.advance( reader, self.query.as_str(), &mut self.match_block, &mut target_at, - ) { + ); + #[cfg(test)] + { + let comparisons = job.last_step.comparisons; + let outputs = job.last_step.output_attempts; + let operations = comparisons + .checked_add(outputs) + .expect("bounded highlight operation counts fit usize"); + let reserves = job + .storage + .reserve_attempts + .checked_sub(reserves_before) + .expect("highlight reserve attempts are monotonic"); + self.highlight_comparisons = self + .highlight_comparisons + .checked_add(comparisons) + .expect("bounded highlight comparison totals fit usize"); + self.highlight_outputs = self + .highlight_outputs + .checked_add(outputs) + .expect("bounded highlight output totals fit usize"); + self.max_highlight_operations = self.max_highlight_operations.max(operations); + self.max_highlight_reserves = self.max_highlight_reserves.max(reserves); + } + match result { Ok(false) => { self.highlights = SearchHighlightState::Pending(job); Ok(false) @@ -516,6 +578,47 @@ impl SearchSession { } } + #[cfg(test)] + pub(super) fn bounded_highlight_metrics(&self) -> (usize, usize, usize, usize, usize) { + ( + self.highlight_reserve_attempts(), + self.highlight_comparisons, + self.highlight_outputs, + self.max_highlight_operations, + self.max_highlight_reserves, + ) + } + + #[cfg(test)] + pub(super) fn bounded_highlight_bytes(&self) -> Option { + let capacity = match &self.highlights { + SearchHighlightState::Absent(storage) + | SearchHighlightState::Disabled { storage, .. } => storage.ranges.capacity(), + SearchHighlightState::Pending(job) => job.storage.ranges.capacity(), + SearchHighlightState::Ready(highlights) => highlights.storage.ranges.capacity(), + }; + capacity.checked_mul(size_of::()) + } + + #[cfg(test)] + pub(super) fn bounded_storage_bytes(&self) -> Option { + let query = self.query.0.capacity(); + let index = self + .index + .checkpoints + .capacity() + .checked_mul(size_of::())?; + let block = self.match_block.bounded_storage_bytes()?; + let navigation = self.navigation.as_ref().map_or(Some(0), |navigation| { + navigation.block.bounded_storage_bytes() + })?; + query + .checked_add(index)? + .checked_add(block)? + .checked_add(navigation)? + .checked_add(self.bounded_highlight_bytes()?) + } + #[cfg(test)] fn pending_highlight_progress( &self, @@ -949,6 +1052,15 @@ fn initial_checkpoint_reservation( } impl MatchBlockCache { + #[cfg(test)] + fn bounded_storage_bytes(&self) -> Option { + let capacity = match self { + Self::Ready(block) => block.storage.starts.capacity(), + Self::Spare(storage) => storage.starts.capacity(), + }; + capacity.checked_mul(size_of::()) + } + const fn is_ready(&self) -> bool { matches!(self, Self::Ready(_)) } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a857401..ba70ed9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -875,7 +875,6 @@ mod tests { infallible(self.inner.flush()) } } - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DrawKind { Pending, @@ -883,6 +882,12 @@ mod tests { Help, } + #[derive(Debug, Default)] + struct BoundedScript { + stage: u8, + turns_in_stage: usize, + } + struct FakeDriver { calls: Vec<&'static str>, failures: Vec<&'static str>, @@ -898,6 +903,8 @@ mod tests { signal_handlers_active: Option>>, draws: Vec, quit_after_reader_draw: bool, + bounded_script: Option, + turn: Option>>, } impl FakeDriver { @@ -917,6 +924,71 @@ mod tests { signal_handlers_active: None, draws: Vec::new(), quit_after_reader_draw: false, + bounded_script: None, + turn: None, + } + } + + fn bounded(signals: &SignalState, turn: Rc>) -> Self { + let mut driver = Self::new(signals); + driver.sizes.push_back((80, 24)); + driver.bounded_script = Some(BoundedScript::default()); + driver.turn = Some(turn); + driver + } + + fn advance_bounded_script(&mut self, app: &mut App, kind: DrawKind) { + if kind != DrawKind::Reader { + return; + } + let Some(script) = &mut self.bounded_script else { + return; + }; + let event = match script.stage { + 0 => Some(document_end_event()), + 1 if app.background_work().is_none() => Some(character_event('/')), + 2 if matches!(app.search_status(), SearchStatus::Draft { draft: "", .. }) => { + Some(character_event('x')) + } + 3 if matches!(app.search_status(), SearchStatus::Draft { draft: "x", .. }) => { + Some(Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })) + } + 4 if app.background_work().is_none() + && matches!( + app.search_status(), + SearchStatus::Committed { + searching: false, + .. + } + ) => + { + Some(character_event('n')) + } + 5 if app.background_work().is_none() + && matches!( + app.search_status(), + SearchStatus::Committed { + searching: false, + .. + } + ) => + { + Some(quit_event()) + } + _ => None, + }; + if let Some(event) = event { + self.events.push_back(event); + script.stage = script + .stage + .checked_add(1) + .expect("bounded script stages fit u8"); + script.turns_in_stage = 0; } } @@ -1043,12 +1115,30 @@ mod tests { self.quit_after_reader_draw = false; self.events.push_back(quit_event()); } + self.advance_bounded_script(app, kind); Ok(()) } fn poll(&mut self, timeout: Duration) -> io::Result { self.poll_timeouts.push(timeout); self.call("poll")?; + if let Some(turn) = &self.turn { + turn.set( + turn.get() + .checked_add(1) + .expect("bounded event-loop turns fit u64"), + ); + } + if let Some(script) = &mut self.bounded_script { + script.turns_in_stage = script + .turns_in_stage + .checked_add(1) + .expect("bounded phase turns fit usize"); + assert!( + script.turns_in_stage <= 100_000, + "bounded workload phase exceeded 100000 event-loop turns" + ); + } Ok(!self.events.is_empty()) } @@ -1086,6 +1176,8 @@ mod tests { suspend_after_render: Option, terminate_after_render: Option, terminate_after_search: Option, + turn: Option>>, + background_turns: Vec, } impl RuntimeRecorder for TraceRecorder { @@ -1099,6 +1191,11 @@ mod tests { fn finish_operation(&mut self, operation: RuntimeOperation, started: Self::Stamp) { self.operations.push((operation, started)); + if matches!(operation, RuntimeOperation::Background(_)) + && let Some(turn) = &self.turn + { + self.background_turns.push(turn.get()); + } if matches!( operation, RuntimeOperation::Background(BackgroundWork::Render) @@ -2570,4 +2667,614 @@ mod tests { assert!(matches!(error, TutError::SignalAndRestoration { .. })); assert_eq!(error.exit_code(), 1); } + + mod bounded_results { + use std::{ + env, + fs::{self, File, OpenOptions}, + io::Write, + path::Path, + rc::Rc, + }; + + use super::*; + use crate::{ + app::{ + BoundedCoreMetrics, visible_render_memory_budget_bytes, + visible_render_refusal_is_typed_and_atomic, + }, + document::{ + MAX_FILE_BYTES, SOURCE_WINDOW_BYTES, max_grapheme_bytes, source_window_max_bytes, + source_window_slop_bytes, + }, + error::LoadError, + layout::{ + MAX_RENDER_GRAPHEME_BYTES, projected_scan_atom_budget, projected_scan_byte_budget, + projected_scan_step_byte_bound, + }, + line_index::memory_budget_bytes as line_index_memory_budget_bytes, + locator::row_neighborhood_capacity, + search::{ + MAX_SEARCH_QUERY_BYTES, display_highlight_merge_budget, match_block_start_limit, + search_highlight_memory_budget_bytes, search_index_memory_budget_bytes, + }, + }; + + const SCHEMA: &str = "tut-bounds/v1"; + const RECORD_LIMIT: usize = 1_024; + const STREAM_LIMIT: usize = 16 * 1_024; + const GENERATION_CHUNK_BYTES: usize = 64 * 1_024; + const MIB: usize = 1024 * 1024; + + #[derive(Debug, Clone, Copy)] + enum WorkloadPattern { + Repeated(&'static str), + Line64k { newline: bool }, + Egc1m, + } + + #[derive(Debug, Clone, Copy)] + struct Workload { + case: &'static str, + source_bytes: usize, + pattern: WorkloadPattern, + } + + const MIXED_UNICODE: &str = "xē•Œ\r\nxšŸ‘©ā€šŸ’»e\u{301}\nalpha x\nx\n"; + const WORKLOADS: [Workload; 12] = [ + Workload { + case: "1m_ascii_lf", + source_bytes: MIB, + pattern: WorkloadPattern::Repeated("alpha x\n"), + }, + Workload { + case: "1m_cjk_lf", + source_bytes: MIB, + pattern: WorkloadPattern::Repeated("ē•Œxē•Œ\n"), + }, + Workload { + case: "1m_emoji_zwj_combining", + source_bytes: MIB, + pattern: WorkloadPattern::Repeated("xšŸ‘©ā€šŸ’»e\u{301}\n"), + }, + Workload { + case: "1m_dense_nul", + source_bytes: MIB, + pattern: WorkloadPattern::Repeated("x\0"), + }, + Workload { + case: "8m_ascii_crlf", + source_bytes: 8 * MIB, + pattern: WorkloadPattern::Repeated("xxxxxx\r\n"), + }, + Workload { + case: "8m_mixed_unicode", + source_bytes: 8 * MIB, + pattern: WorkloadPattern::Repeated(MIXED_UNICODE), + }, + Workload { + case: "8m_line_64k", + source_bytes: 8 * MIB, + pattern: WorkloadPattern::Line64k { newline: true }, + }, + Workload { + case: "8m_no_newline", + source_bytes: 8 * MIB, + pattern: WorkloadPattern::Line64k { newline: false }, + }, + Workload { + case: "32m_ascii_lf", + source_bytes: 32 * MIB, + pattern: WorkloadPattern::Repeated("alpha x\n"), + }, + Workload { + case: "32m_dense_nul", + source_bytes: 32 * MIB, + pattern: WorkloadPattern::Repeated("x\0"), + }, + Workload { + case: "32m_no_newline", + source_bytes: 32 * MIB, + pattern: WorkloadPattern::Line64k { newline: false }, + }, + Workload { + case: "32m_egc_1m_x32", + source_bytes: 32 * MIB, + pattern: WorkloadPattern::Egc1m, + }, + ]; + + #[derive(Debug, Default)] + struct RecorderTotals { + cases: usize, + events: u64, + operations: usize, + max_background_per_turn: usize, + } + + struct ResultWriter { + file: File, + records: usize, + bytes: usize, + } + + impl ResultWriter { + fn new(path: &Path) -> Self { + Self { + file: OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .expect("create bounded result stream"), + records: 0, + bytes: 0, + } + } + + fn record(&mut self, record: String) { + assert!(record.is_ascii(), "bounded records are ASCII"); + assert!(record.ends_with('\n'), "bounded records end with LF"); + assert!(!record[..record.len() - 1].contains(['\n', '\r'])); + assert!( + record.len() <= RECORD_LIMIT, + "bounded record exceeds 1024 bytes" + ); + self.bytes = self + .bytes + .checked_add(record.len()) + .expect("bounded stream byte count fits usize"); + assert!(self.bytes <= STREAM_LIMIT, "bounded stream exceeds 16 KiB"); + self.records = self + .records + .checked_add(1) + .expect("bounded record count fits usize"); + self.file + .write_all(record.as_bytes()) + .expect("write bounded result record"); + } + + fn finish(mut self) { + assert_eq!(self.records, 15); + self.file.flush().expect("flush bounded result stream"); + } + } + + fn checked_u64(value: usize) -> u64 { + u64::try_from(value).expect("bounded counter fits unsigned 64-bit output") + } + + fn write_workload(path: &Path, workload: Workload) { + assert_eq!(MIXED_UNICODE.len(), 32); + assert_eq!(workload.source_bytes % GENERATION_CHUNK_BYTES, 0); + let mut file = File::create(path).expect("create bounded workload file"); + let mut buffer = [0_u8; GENERATION_CHUNK_BYTES]; + match workload.pattern { + WorkloadPattern::Repeated(pattern) => { + let pattern = pattern.as_bytes(); + assert_eq!(GENERATION_CHUNK_BYTES % pattern.len(), 0); + for chunk in buffer.chunks_exact_mut(pattern.len()) { + chunk.copy_from_slice(pattern); + } + for _ in 0..workload.source_bytes / GENERATION_CHUNK_BYTES { + file.write_all(&buffer) + .expect("write bounded workload chunk"); + } + } + WorkloadPattern::Line64k { newline } => { + buffer.fill(b'a'); + buffer[0] = b'x'; + if newline { + buffer[GENERATION_CHUNK_BYTES - 1] = b'\n'; + } + for _ in 0..workload.source_bytes / GENERATION_CHUNK_BYTES { + file.write_all(&buffer) + .expect("write bounded workload chunk"); + } + } + WorkloadPattern::Egc1m => { + let prefix = "x\u{20dd}".as_bytes(); + let combining = "\u{301}".as_bytes(); + assert_eq!(prefix.len(), 4); + assert_eq!(combining.len(), 2); + assert_eq!(MIB % GENERATION_CHUNK_BYTES, 0); + for _ in 0..workload.source_bytes / MIB { + buffer[..prefix.len()].copy_from_slice(prefix); + for chunk in buffer[prefix.len()..].chunks_exact_mut(combining.len()) { + chunk.copy_from_slice(combining); + } + file.write_all(&buffer).expect("write first EGC chunk"); + for chunk in buffer.chunks_exact_mut(combining.len()) { + chunk.copy_from_slice(combining); + } + for _ in 1..MIB / GENERATION_CHUNK_BYTES { + file.write_all(&buffer).expect("write remaining EGC chunk"); + } + } + } + } + file.flush().expect("flush bounded workload file"); + assert_eq!( + file.metadata().expect("inspect bounded workload").len(), + checked_u64(workload.source_bytes) + ); + } + + fn max_background_per_turn(turns: &[u64]) -> usize { + let mut maximum = 0; + let mut previous = None; + let mut current = 0_usize; + for &turn in turns { + if previous == Some(turn) { + current = current + .checked_add(1) + .expect("bounded per-turn operation count fits usize"); + } else { + previous = Some(turn); + current = 1; + } + maximum = maximum.max(current); + } + maximum + } + + fn count_operation(recorder: &TraceRecorder, expected: RuntimeOperation) -> usize { + recorder + .operations + .iter() + .filter(|(operation, _)| *operation == expected) + .count() + } + + fn run_workload(path: &Path, workload: Workload, totals: &mut RecorderTotals) -> String { + write_workload(path, workload); + let document = + crate::document::load(path.to_path_buf()).expect("load bounded workload"); + let mut app = App::new(document); + let signals = SignalState::empty(); + let turn = Rc::new(StateCell::new(0_u64)); + let mut driver = FakeDriver::bounded(&signals, Rc::clone(&turn)); + let mut recorder = TraceRecorder { + turn: Some(turn), + ..TraceRecorder::default() + }; + + assert_eq!( + run_with_test_recorder(&mut app, &mut driver, &signals, &mut recorder) + .expect("run bounded workload"), + RunOutcome::Normal + ); + assert_eq!( + driver + .bounded_script + .as_ref() + .expect("bounded workload retains its script") + .stage, + 6 + ); + + let metrics = app.bounded_core_metrics(); + assert_core_gates(metrics); + let background_per_turn = max_background_per_turn(&recorder.background_turns); + assert_eq!(background_per_turn, 1); + + totals.cases = totals + .cases + .checked_add(1) + .expect("bounded case count fits usize"); + totals.events = totals + .events + .checked_add(recorder.events) + .expect("bounded event total fits u64"); + totals.operations = totals + .operations + .checked_add(recorder.operations.len()) + .expect("bounded operation total fits usize"); + totals.max_background_per_turn = + totals.max_background_per_turn.max(background_per_turn); + + let draws = count_operation(&recorder, RuntimeOperation::Draw); + let line_steps = count_operation( + &recorder, + RuntimeOperation::Background(BackgroundWork::LineIndex), + ); + let viewport_steps = count_operation( + &recorder, + RuntimeOperation::Background(BackgroundWork::Viewport), + ); + let render_steps = count_operation( + &recorder, + RuntimeOperation::Background(BackgroundWork::Render), + ); + let search_steps = count_operation( + &recorder, + RuntimeOperation::Background(BackgroundWork::Search), + ); + let record = format!( + "{{\"schema\":\"{SCHEMA}\",\"kind\":\"core\",\"case\":\"{}\",\"source_bytes\":{},\"draws\":{},\"line_steps\":{},\"viewport_steps\":{},\"render_steps\":{},\"search_steps\":{},\"window_calls\":{},\"byte_window_calls\":{},\"grapheme_window_calls\":{},\"grapheme_window_bytes\":{},\"utf8_bytes\":{},\"segmentation_runs\":{},\"segmentation_bytes\":{},\"grapheme_emissions\":{},\"max_search_windows\":{},\"max_projected_emissions\":{},\"max_projected_bytes\":{},\"render_reserves\":{},\"highlight_reserves\":{},\"highlight_comparisons\":{},\"highlight_outputs\":{},\"peak_render_bytes\":{},\"peak_highlight_bytes\":{}}}\n", + workload.case, + checked_u64(workload.source_bytes), + checked_u64(draws), + checked_u64(line_steps), + checked_u64(viewport_steps), + checked_u64(render_steps), + checked_u64(search_steps), + checked_u64(metrics.window_calls), + checked_u64(metrics.byte_window_calls), + checked_u64(metrics.grapheme_window_calls), + checked_u64(metrics.grapheme_window_bytes), + checked_u64(metrics.utf8_bytes), + checked_u64(metrics.segmentation_runs), + checked_u64(metrics.segmentation_bytes), + checked_u64(metrics.grapheme_emissions), + checked_u64(metrics.max_search_windows), + checked_u64(metrics.max_projected_emissions), + checked_u64(metrics.max_projected_bytes), + checked_u64(metrics.render_reserves), + checked_u64(metrics.highlight_reserves), + checked_u64(metrics.highlight_comparisons), + checked_u64(metrics.highlight_outputs), + checked_u64(metrics.peak_render_bytes), + checked_u64(metrics.peak_highlight_bytes), + ); + drop(app); + fs::remove_file(path).expect("remove bounded workload after case"); + record + } + + fn assert_core_gates(metrics: BoundedCoreMetrics) { + assert!(!metrics.line_step_violation); + assert!(metrics.max_line_step_bytes > 0); + assert!(metrics.max_line_step_bytes <= source_window_max_bytes()); + assert!(metrics.max_search_windows <= 1); + assert!(metrics.max_projected_emissions <= projected_scan_atom_budget()); + assert!(checked_u64(metrics.max_projected_bytes) <= projected_scan_step_byte_bound()); + let grapheme_read_limit = max_grapheme_bytes() + .checked_add(SOURCE_WINDOW_BYTES) + .and_then(|bytes| bytes.checked_add(4)) + .expect("grapheme read gate fits usize"); + assert!(metrics.max_grapheme_window_bytes <= grapheme_read_limit); + assert!(metrics.max_highlight_operations <= display_highlight_merge_budget()); + assert!(metrics.max_highlight_reserves <= 1); + assert!(metrics.peak_render_bytes <= visible_render_memory_budget_bytes()); + assert!(metrics.peak_highlight_bytes <= search_highlight_memory_budget_bytes()); + } + + fn refusal_counts() -> (usize, usize) { + let directory = tempdir().expect("create refusal probe directory"); + let path = directory.path().join("source-limit"); + File::create(&path) + .expect("create source refusal probe") + .set_len(MAX_FILE_BYTES + 1) + .expect("size source refusal probe"); + let source_typed = matches!( + crate::document::load(path.clone()), + Err(LoadError::TooLarge { + limit: MAX_FILE_BYTES, + .. + }) + ); + let source_atomic = + fs::metadata(&path).expect("inspect refused source").len() == MAX_FILE_BYTES + 1; + let terminal_typed = matches!( + TerminalSize::new(4096, 129), + Err(TutError::TerminalTooLarge { .. }) + ); + let terminal_atomic = TerminalSize::new(4096, 128).is_ok(); + let render_typed_and_atomic = visible_render_refusal_is_typed_and_atomic(); + let typed = [source_typed, terminal_typed, render_typed_and_atomic] + .into_iter() + .filter(|passed| *passed) + .count(); + let atomic = [source_atomic, terminal_atomic, render_typed_and_atomic] + .into_iter() + .filter(|passed| *passed) + .count(); + assert_eq!((typed, atomic), (3, 3)); + (typed, atomic) + } + + fn limits_record() -> String { + let (typed_refusals, atomic_refusals) = refusal_counts(); + format!( + "{{\"schema\":\"{SCHEMA}\",\"kind\":\"limits\",\"case\":\"hard_limits\",\"source_bytes\":{},\"source_window_target_bytes\":{},\"source_window_slop_bytes\":{},\"source_window_bytes\":{},\"grapheme_chunk_bytes\":{},\"render_grapheme_bytes\":{},\"projected_atoms\":{},\"projected_target_bytes\":{},\"projected_step_bytes\":{},\"terminal_cells\":{},\"terminal_buffer_bytes\":{},\"visible_render_bytes\":{},\"line_index_bytes\":{},\"search_index_bytes\":{},\"highlight_bytes\":{},\"query_bytes\":{},\"match_block_starts\":{},\"row_edges\":{},\"highlight_operations\":{},\"typed_refusals\":{},\"atomic_refusals\":{}}}\n", + MAX_FILE_BYTES, + checked_u64(SOURCE_WINDOW_BYTES), + checked_u64(source_window_slop_bytes()), + checked_u64(source_window_max_bytes()), + checked_u64(max_grapheme_bytes()), + checked_u64(MAX_RENDER_GRAPHEME_BYTES), + checked_u64(projected_scan_atom_budget()), + projected_scan_byte_budget(), + projected_scan_step_byte_bound(), + MAX_TERMINAL_CELLS, + MAX_TERMINAL_BUFFER_BYTES, + checked_u64(visible_render_memory_budget_bytes()), + checked_u64(line_index_memory_budget_bytes()), + checked_u64(search_index_memory_budget_bytes()), + checked_u64(search_highlight_memory_budget_bytes()), + checked_u64(MAX_SEARCH_QUERY_BYTES), + checked_u64(match_block_start_limit()), + checked_u64(row_neighborhood_capacity()), + checked_u64(display_highlight_merge_budget()), + checked_u64(typed_refusals), + checked_u64(atomic_refusals), + ) + } + + fn input_preemption_count() -> usize { + let signals = SignalState::empty(); + let mut driver = FakeDriver::new(&signals); + driver.events.push_back(quit_event()); + let mut recorder = TraceRecorder::default(); + let mut app = app(); + let outcome = run_with_test_recorder(&mut app, &mut driver, &signals, &mut recorder) + .expect("run input-preemption probe"); + usize::from( + outcome == RunOutcome::Normal + && app.has_background_work() + && recorder.operations.iter().all(|(operation, _)| { + !matches!(operation, RuntimeOperation::Background(_)) + }), + ) + } + + fn signal_preemption_count() -> usize { + let signals = SignalState::empty(); + let mut driver = FakeDriver::new(&signals); + driver.inject_on = Some("poll"); + let mut recorder = TraceRecorder::default(); + let mut app = app(); + let outcome = run_with_test_recorder(&mut app, &mut driver, &signals, &mut recorder) + .expect("run signal-preemption probe"); + usize::from( + outcome == RunOutcome::Signal(ExternalSignal::Terminate) + && app.has_background_work() + && recorder.operations.is_empty(), + ) + } + + fn run_warm_cycle(app: &mut App) { + let signals = SignalState::empty(); + let turn = Rc::new(StateCell::new(0_u64)); + let mut driver = FakeDriver::bounded(&signals, Rc::clone(&turn)); + let mut recorder = TraceRecorder { + turn: Some(turn), + ..TraceRecorder::default() + }; + assert_eq!( + run_with_test_recorder(app, &mut driver, &signals, &mut recorder) + .expect("run warm bounded cycle"), + RunOutcome::Normal + ); + assert_eq!(max_background_per_turn(&recorder.background_turns), 1); + } + + fn warm_capacities() -> (usize, usize) { + let mut app = app_from_text(Path::new("/bounded/warm"), "alpha x\n".repeat(16 * 1024)); + run_warm_cycle(&mut app); + let warm = app + .bounded_capacity_bytes() + .expect("warm bounded capacities fit usize"); + run_warm_cycle(&mut app); + let after = app + .bounded_capacity_bytes() + .expect("post-warm bounded capacities fit usize"); + let growth = after.saturating_sub(warm); + assert!(after <= warm); + assert_eq!(growth, 0); + (warm, growth) + } + + fn partial_publication_counts() -> (usize, usize) { + let signals = SignalState::empty(); + let mut frame_driver = FakeDriver::new(&signals); + frame_driver.sizes.push_back((4096, 4)); + let mut frame_recorder = TraceRecorder { + terminate_after_render: Some(signals.clone()), + ..TraceRecorder::default() + }; + let mut frame_app = + app_from_text(Path::new("/bounded/partial-frame"), "x".repeat(4096)); + assert_eq!( + run_with_test_recorder( + &mut frame_app, + &mut frame_driver, + &signals, + &mut frame_recorder, + ) + .expect("run partial-frame probe"), + RunOutcome::Signal(ExternalSignal::Terminate) + ); + assert_eq!( + count_operation( + &frame_recorder, + RuntimeOperation::Background(BackgroundWork::Render), + ), + 1 + ); + let frame_ready = frame_app.frame_ready(); + let partial_frames = usize::from(frame_ready); + assert!(!frame_ready); + assert_eq!(frame_app.background_work(), Some(BackgroundWork::Render)); + + let highlight_signals = SignalState::empty(); + let mut highlight_driver = FakeDriver::new(&highlight_signals); + let mut highlight_recorder = TraceRecorder { + terminate_after_search: Some(highlight_signals.clone()), + ..TraceRecorder::default() + }; + let mut highlight_app = merge_pending_app(); + let highlight_before = highlight_app + .pending_highlight_cursors() + .expect("highlight probe starts with pending cursors"); + assert!(matches!( + event_loop( + &mut highlight_app, + &mut highlight_driver, + &highlight_signals, + &mut highlight_recorder, + ), + Primary::Signal(ExternalSignal::Terminate) + )); + assert_eq!( + count_operation( + &highlight_recorder, + RuntimeOperation::Background(BackgroundWork::Search), + ), + 1 + ); + let highlight_after = highlight_app + .pending_highlight_cursors() + .expect("one highlight phase remains pending"); + assert_ne!(highlight_after, highlight_before); + let partial_highlights = usize::from(highlight_app.published_highlight_count() != 0); + (partial_frames, partial_highlights) + } + + fn recorder_record(totals: RecorderTotals) -> String { + let input_preemptions = input_preemption_count(); + let signal_preemptions = signal_preemption_count(); + assert_eq!((input_preemptions, signal_preemptions), (1, 1)); + let (warm_capacity_bytes, post_warm_growth_bytes) = warm_capacities(); + let (partial_frames, partial_highlights) = partial_publication_counts(); + assert_eq!(totals.cases, WORKLOADS.len()); + assert_eq!(totals.max_background_per_turn, 1); + assert_eq!((partial_frames, partial_highlights), (0, 0)); + format!( + "{{\"schema\":\"{SCHEMA}\",\"kind\":\"recorder\",\"case\":\"fake_loop\",\"cases\":{},\"recorded_events\":{},\"recorded_operations\":{},\"max_background_per_turn\":{},\"input_preemptions\":{},\"signal_preemptions\":{},\"warm_capacity_bytes\":{},\"post_warm_growth_bytes\":{},\"partial_frames\":{},\"partial_highlights\":{}}}\n", + checked_u64(totals.cases), + totals.events, + checked_u64(totals.operations), + checked_u64(totals.max_background_per_turn), + checked_u64(input_preemptions), + checked_u64(signal_preemptions), + checked_u64(warm_capacity_bytes), + checked_u64(post_warm_growth_bytes), + checked_u64(partial_frames), + checked_u64(partial_highlights), + ) + } + + #[test] + #[ignore = "run only through make bounded-results"] + fn bounded_results_core() { + let output = env::var_os("TUT_BOUNDED_RESULTS_FILE") + .expect("TUT_BOUNDED_RESULTS_FILE is set by make bounded-results"); + let output = Path::new(&output); + let directory = tempdir().expect("create bounded workload directory"); + let workload_path = directory.path().join("workload"); + let mut writer = ResultWriter::new(output); + writer.record(format!( + "{{\"schema\":\"{SCHEMA}\",\"kind\":\"schema\",\"records\":16,\"core_records\":12,\"record_bytes\":1024,\"stream_bytes\":16384}}\n" + )); + let mut totals = RecorderTotals::default(); + for workload in WORKLOADS { + writer.record(run_workload(&workload_path, workload, &mut totals)); + } + writer.record(limits_record()); + writer.record(recorder_record(totals)); + writer.finish(); + } + } } diff --git a/tests/cli.rs b/tests/cli.rs index de1ffd1..d906af8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -139,7 +139,7 @@ fn diagnostics_escape_control_bytes_in_arguments() { mod pty { use std::{ ffi::CString, - fs::File, + fs::{File, OpenOptions}, io::{self, Read, Write}, os::unix::process::{CommandExt, ExitStatusExt}, path::Path, @@ -826,6 +826,104 @@ mod pty { } } + #[test] + #[ignore = "run only through make bounded-results"] + fn bounded_results_pty() { + const SCHEMA_RECORD: &str = "{\"schema\":\"tut-bounds/v1\",\"kind\":\"schema\",\"records\":16,\"core_records\":12,\"record_bytes\":1024,\"stream_bytes\":16384}\n"; + const RECORD_LIMIT: usize = 1_024; + const STREAM_LIMIT: usize = 16 * 1_024; + + let output = std::env::var_os("TUT_BOUNDED_RESULTS_FILE") + .expect("TUT_BOUNDED_RESULTS_FILE is set by make bounded-results"); + let directory = tempdir().unwrap(); + let input = directory.path().join("private-input.txt"); + let log = directory.path().join("session.log"); + std::fs::write(&input, "BOUNDED_PRIVATE_TEXT\nx BOUNDED_PRIVATE_QUERY\n").unwrap(); + + let mut pty = PtyChild::spawn_logged(&input, Some(&log), None).unwrap(); + pty.wait_for(HIDE_CURSOR).unwrap(); + pty.master.write_all(b"/BOUNDED_PRIVATE_QUERY\rq").unwrap(); + let status = pty.wait().unwrap(); + let exit_code = status.code().expect("bounded PTY child exits normally"); + assert_eq!(exit_code, 0); + assert!(pty.stderr_output.is_empty()); + let cleanup_sequences = [ENTER_ALT, HIDE_CURSOR, SHOW_CURSOR, LEAVE_ALT] + .into_iter() + .filter(|sequence| { + pty.output + .windows(sequence.len()) + .any(|window| window == *sequence) + }) + .count(); + assert_eq!(cleanup_sequences, 4); + pty.assert_restored(); + let termios_restored = 1_usize; + + let log_bytes = std::fs::read(&log).unwrap(); + let log_ascii = usize::from(log_bytes.is_ascii()); + assert_eq!(log_ascii, 1); + assert!(log_bytes.ends_with(b"\n")); + let log_records = log_bytes.split(|byte| *byte == b'\n').count() - 1; + assert_eq!(log_records, 4); + let log_text = String::from_utf8(log_bytes).unwrap(); + let forbidden = [ + input.to_string_lossy().into_owned(), + input.file_name().unwrap().to_string_lossy().into_owned(), + "BOUNDED_PRIVATE_TEXT".to_owned(), + "BOUNDED_PRIVATE_QUERY".to_owned(), + ]; + let forbidden_values = forbidden + .iter() + .filter(|value| log_text.contains(value.as_str())) + .count(); + assert_eq!(forbidden_values, 0); + + let record = format!( + "{{\"schema\":\"tut-bounds/v1\",\"kind\":\"pty\",\"case\":\"cleanup_log\",\"exit_code\":{},\"cleanup_sequences\":{},\"termios_restored\":{},\"log_records\":{},\"log_ascii\":{},\"forbidden_values\":{}}}\n", + u64::try_from(exit_code).unwrap(), + u64::try_from(cleanup_sequences).unwrap(), + u64::try_from(termios_restored).unwrap(), + u64::try_from(log_records).unwrap(), + u64::try_from(log_ascii).unwrap(), + u64::try_from(forbidden_values).unwrap(), + ); + assert!(record.is_ascii()); + assert!(record.ends_with('\n')); + assert!(record.len() <= RECORD_LIMIT); + let mut file = OpenOptions::new().append(true).open(&output).unwrap(); + file.write_all(record.as_bytes()).unwrap(); + file.flush().unwrap(); + drop(file); + + let stream = std::fs::read(&output).unwrap(); + assert!(stream.is_ascii()); + assert!(stream.len() <= STREAM_LIMIT); + assert!(stream.ends_with(b"\n")); + let records = stream + .split_inclusive(|byte| *byte == b'\n') + .collect::>(); + assert_eq!(records.len(), 16); + assert_eq!(records[0], SCHEMA_RECORD.as_bytes()); + assert_eq!( + records + .iter() + .filter(|record| { + record + .windows(b"\"kind\":\"core\"".len()) + .any(|window| window == b"\"kind\":\"core\"") + }) + .count(), + 12 + ); + for record in records { + assert!(record.len() <= RECORD_LIMIT); + assert!(record.starts_with(b"{\"schema\":\"tut-bounds/v1\",\"kind\":")); + assert!(record.ends_with(b"}\n")); + assert_eq!(record.iter().filter(|byte| **byte == b'\n').count(), 1); + assert!(!record.contains(&b'\r')); + } + } + #[test] fn navigation_and_search_work_through_a_real_terminal() { let file = NamedTempFile::new().unwrap();