From b91b922b5fc27936dfe56a36a0740881c39c0469 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 03:56:25 +0200 Subject: [PATCH 1/5] Measure what the commands cost, and save a baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-eight commands and not one number attached to any of them. criterion benchmarks below the command boundary, against a file-backed database of 800 notes of ~13 kB — the shape already quoted in notes/view.rs. The first baseline is in docs/architecture.md. Two things it says: query_notes costs 27 ms on 800 notes and runs on every keystroke, and the search is not what costs — filtering is cheaper than not filtering, so the accent fold is not the dominant term. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 + docs/architecture.md | 61 +++++++++ src-tauri/Cargo.lock | 148 +++++++++++++++++++++ src-tauri/Cargo.toml | 34 +++++ src-tauri/benches/commands.rs | 233 ++++++++++++++++++++++++++++++++++ src-tauri/benches/corpus.rs | 140 ++++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 src-tauri/benches/commands.rs create mode 100644 src-tauri/benches/corpus.rs diff --git a/CLAUDE.md b/CLAUDE.md index b446c51..f0f0f90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ Run all commands from the repo root (`package.json` there wraps both Angular and - `npm run e2e:build` then `npm run test:e2e` — the end-to-end suite (WebdriverIO + `tauri-driver`), twelve scenarios under `e2e/specs/` against the **assembled** application. The build step is not optional after a change to `src/` or `src-tauri/`: the suite drives a binary with the front end compiled into it. +- `cargo bench` from `src-tauri/` — criterion, against a **file-backed** database of 800 notes of ~13 kB, below the command boundary. Deliberately **not in CI**: a timing assertion on a shared runner flaps. `--save-baseline main` then `--baseline main` is the comparison the harness exists for. ⚠️ `autobenches = false` and `bench = false` on the lib and both bins are load-bearing, and commented in `Cargo.toml`. The first baseline is in `docs/architecture.md`: `query_notes` costs **27 ms on 800 notes**, and the search is not what costs — filtering is cheaper than not filtering. + - `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` forbids `unsafe_code`, denies `clippy::all` and warns on `clippy::pedantic`, `rust_2018_idioms` and `unreachable_pub`. The toolchain is pinned in `rust-toolchain.toml`, so a new stable release can't turn CI red on an untouched commit. ## Things that will bite you diff --git a/docs/architecture.md b/docs/architecture.md index 7582b51..59e2c8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2010,6 +2010,67 @@ release that replayed it would pay for it twice. ## Testing +### Benchmarks + +`cargo bench` from `src-tauri/`. They are **not in CI**: a timing assertion on a shared +runner flaps, and a suite that flaps is a suite everyone learns to ignore. They run +locally, on demand. + +```bash +cargo bench -- --save-baseline main # before +cargo bench -- --baseline main # after +``` + +That comparison is the whole reason the harness is **criterion** and not divan, which is +lighter and pleasanter but does not offer it out of the box. + +⚠️ **They run below the command boundary, not through Tauri.** A command is four lines — +validate, lock, delegate, translate the error — so `store::*` plus `view::*` plus the serde +round-trip captures nearly all of the cost. `tauri::test::mock_app` would drag the whole app +lifecycle in and buy only the IPC transport, which this codebase does not control. So these +numbers are **not** "the IPC is fast": they are what the work behind a command costs. +Serialisation is included on purpose — a `NotesView` over 800 notes is a real `serde_json` +cost paid on every keystroke. + +⚠️ **The corpus is file-backed, never `open_in_memory`.** An in-memory database has no pager +behind a file, no page cache doing real work and no I/O at all — it measures something the +application never does. `benches/corpus.rs` writes 800 notes of ~13 kB into a temporary file +database, the shape already quoted in `notes/view.rs`, and its bodies are accented on +purpose: a pure-ASCII corpus would exercise only `fold`'s fast path. + +Two Cargo details exist solely to make this work, both commented in `Cargo.toml`: +`autobenches = false` (or the shared corpus module is discovered as a benchmark of its own +and reported as entirely unused) and `bench = false` on the lib and both bins (or cargo runs +their built-in harness first, which rejects criterion's own flags). + +#### The first baseline + +Taken on 800 notes, Windows, release profile with `lto = true`: + +| Command | Cost | +| ---------------------------------------------- | ---------------- | +| `query_notes`, unfiltered | **27.3 ms** | +| `query_notes`, search matching nothing | 26.8 ms | +| `query_notes`, search folding accents | 25.8 ms | +| `delete_notes` then `restore_notes`, 100 notes | 9.0 ms | +| `export_notes` / `import_notes` | 26.7 ms / 8.3 ms | +| `rename_tag` across the corpus | 3.8 ms | +| `move_notes` / `tag_notes`, 100 notes | 2.7 ms / 2.3 ms | +| `update_note` | 1.5 ms | +| `list_tags` / `list_trash` | 591 µs / 424 µs | +| `list_global_placeholders` | 1.6 µs | + +Two things worth reading off that table. + +**`query_notes` costs 27 ms and runs on every keystroke**, behind the 150 ms debounce. It is +comfortably inside the debounce at this size, and it is the number #21 is about: the cost is +linear in the corpus, so five thousand notes would put it past a tenth of a second. + +**The search is not what costs.** Filtering is _slightly cheaper_ than not filtering — fewer +notes to serialise — so the accent fold is not the dominant term at all. Fetching 800 × 13 kB +out of SQLite and turning the view into JSON is. Work aimed at making search faster would be +aimed at the wrong half. + Unit tests run with Vitest through the `@angular/build:unit-test` builder in a jsdom environment (configured in `angular.json`'s `test` target and `vitest-base.config.ts`), so no browser is needed. Specs sit next to the file they cover. Coverage thresholds are set at diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb868d7..a11fef0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -38,6 +38,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "android_log-sys" version = "0.3.2" @@ -64,6 +73,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -538,6 +559,12 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.3.0" @@ -605,6 +632,58 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -711,6 +790,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -908,6 +1020,7 @@ version = "0.1.4" dependencies = [ "base64 0.23.1", "chrono", + "criterion", "diesel", "diesel_migrations", "libsqlite3-sys", @@ -2282,6 +2395,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2986,6 +3108,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.0" @@ -3043,6 +3171,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -5031,6 +5169,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02bcb6b..1990762 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,22 @@ repository = "https://github.com/vmillet-dev/devbox-rs" license = "GPL-3.0-only" readme = "../README.md" default-run = "devbox" +# ⚠️ Off, and the [[bench]] table below is why. Cargo would otherwise discover every +# benches/*.rs as a target of its own — including the shared corpus, which is a module +# and not a benchmark, and which clippy then reports as entirely unused. +autobenches = false + +# Both binaries are auto-discovered; these tables exist only to turn the built-in +# harness off, for the reason given on [lib]. +[[bin]] +name = "devbox" +path = "src/main.rs" +bench = false + +[[bin]] +name = "export-bindings" +path = "src/bin/export-bindings.rs" +bench = false [package.metadata.devbox] display-name = "DevBox" @@ -18,6 +34,10 @@ author-handle = "@vmillet-dev" [lib] name = "devbox_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# ⚠️ The lib carries no benchmarks, but cargo gives every target the built-in test +# harness by default and runs it first — which then rejects criterion's own flags with +# "Unrecognized option: save-baseline". +bench = false [features] # The end-to-end harness, and nothing else. Off by default, so a release build @@ -73,6 +93,20 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" +# ⚠️ The first dev-dependency, and `deny.toml` sets `all-features = true`, so cargo-deny +# inspects this tree too: criterion's dependencies answer to the licence allow-list. +# +# criterion and not divan, for the one reason that decides it: `--save-baseline` and +# `--baseline`. A baseline is only worth keeping if a later state can be compared to it in +# one command. +[dev-dependencies] +criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } + +# `harness = false`: criterion brings its own main. +[[bench]] +name = "commands" +harness = false + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs new file mode 100644 index 0000000..7307b9a --- /dev/null +++ b/src-tauri/benches/commands.rs @@ -0,0 +1,233 @@ +//! What the IPC surface costs, by class of command. +//! +//! ⚠️ **Below the command boundary, not through Tauri.** A command is meant to be four +//! lines — validate, lock, delegate, translate the error — and that rule holds across the +//! surface, so `store::*` plus `view::*` plus the serde round-trip of the payload captures +//! nearly all of the cost. `tauri::test::mock_app` exists, but dragging the whole app +//! lifecycle in buys only the IPC transport, which this codebase does not control. +//! +//! **So these numbers are not "the IPC is fast".** They are what the work behind a command +//! costs. Serialisation *is* included and deliberately so: a `NotesView` over 800 notes is +//! a real `serde_json` cost paid on every keystroke, and it belongs in the number. +//! +//! The three commands in `desktop.rs` are out of scope: they touch no database and stay on +//! the main thread on purpose. +//! +//! ``` +//! cargo bench -- --save-baseline main +//! # … whatever changes … +//! cargo bench -- --baseline main +//! ``` + +mod corpus; + +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; + +use devbox_lib::attachments::store as attachments; +use devbox_lib::notes::model::NotePatch; +use devbox_lib::notes::store; +use devbox_lib::notes::view::{self, NoteFilter, NotesQuery}; +use devbox_lib::transfer::{bundle, file}; + +use corpus::{Corpus, NOTES, build, now}; + +fn query(search: &str) -> NotesQuery { + NotesQuery { + space_id: None, + search: search.to_string(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: now(), + tz_offset_minutes: -120, + pinned_first: true, + } +} + +/// Everything `query_notes` does, in its order, serialisation included. +fn run_query(corpus: &mut Corpus, request: &NotesQuery) -> String { + let (notes, facets) = store::fetch(&mut corpus.connection, request).expect("a view"); + let counts = attachments::counts(&mut corpus.connection).expect("the counters"); + let globals = store::global_placeholder_values(&mut corpus.connection).expect("the globals"); + + let mut built = view::build(notes, facets, request); + view::apply_attachment_counts(&mut built, &counts); + view::apply_global_defaults(&mut built, &globals); + + serde_json::to_string(&built).expect("a serialisable view") +} + +/// The one number that matters most: it runs on every keystroke, behind the 150 ms +/// debounce, and #21 says it reads the whole corpus to do it. +fn whole_corpus_read(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("whole-corpus read"); + + group.bench_function("query_notes, unfiltered", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("")))); + }); + + // Matching runs on the fetched rows, not in SQL — the needle decides the cost. + group.bench_function("query_notes, search matching nothing", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("zzz-no-such-needle")))); + }); + + // ⚠️ Unaccented on purpose: the fold has to strip the accents off every byte of the + // corpus before it can answer, which is the branch the 6.3 ms figure was taken on. + group.bench_function("query_notes, search folding accents", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("deploiement")))); + }); + + group.finish(); +} + +/// The editor's round trip, once per field committed. +fn single_write(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("single write"); + + group.bench_function("update_note", |b| { + let id = corpus.note_ids[NOTES / 2].clone(); + let mut at = 0u32; + b.iter(|| { + at += 1; + let patch = NotePatch { + title: Some(format!("Retitled {at}")), + ..NotePatch::default() + }; + black_box(store::update(&mut corpus.connection, &id, &patch, now()).expect("a write")); + }); + }); + + group.finish(); +} + +/// One lock, N rows. The selection bar's actions. +fn bulk(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("bulk over 100 notes"); + let ids: Vec = corpus.note_ids.iter().take(100).cloned().collect(); + + group.bench_function("tag_notes", |b| { + b.iter(|| { + black_box( + store::tag_many(&mut corpus.connection, &ids, &["bulk".to_string()], now()) + .expect("a batch"), + ); + }); + }); + + group.bench_function("move_notes", |b| { + let mut at = 0usize; + b.iter(|| { + at += 1; + let target = &corpus.space_ids[at % corpus.space_ids.len()]; + black_box( + store::move_many(&mut corpus.connection, &ids, target, now()).expect("a batch"), + ); + }); + }); + + group.bench_function("delete_notes then restore_notes", |b| { + b.iter(|| { + store::trash::trash_many(&mut corpus.connection, &ids, now()).expect("a trashing"); + black_box( + store::trash::restore_many(&mut corpus.connection, &ids).expect("a restoration"), + ); + }); + }); + + group.finish(); +} + +/// The facet and panel queries, each one a pass over a side table. +fn aggregation(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("corpus-wide aggregation"); + + group.bench_function("list_tags", |b| { + b.iter(|| black_box(store::tag_usage(&mut corpus.connection).expect("the tags"))); + }); + + group.bench_function("list_trash, nothing trashed", |b| { + b.iter(|| { + black_box(store::trash::list_trashed(&mut corpus.connection).expect("the trash")); + }); + }); + + group.bench_function("list_global_placeholders", |b| { + b.iter(|| { + black_box( + store::global_placeholder_values(&mut corpus.connection).expect("the globals"), + ); + }); + }); + + group.finish(); +} + +/// `retag` touches every matching row, in one transaction. +fn corpus_rewrite(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("corpus-wide rewrite"); + + group.bench_function("rename_tag", |b| { + let mut at = 0u32; + b.iter(|| { + at += 1; + let (from, into) = if at.is_multiple_of(2) { + ("ops", "operations") + } else { + ("operations", "ops") + }; + black_box( + store::retag(&mut corpus.connection, &[from.to_string()], into).expect("a retag"), + ); + }); + }); + + group.finish(); +} + +/// The bundle both ways, against a real file. +fn disk(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("disk"); + // 800 notes of 13 kB is a ~10 MB bundle: long enough that these two want their own + // sample size rather than criterion's hundred. + group.sample_size(10); + + let path = std::env::temp_dir().join("devbox-bench-export.json"); + let target = path.to_string_lossy().to_string(); + + group.bench_function("export_notes", |b| { + b.iter(|| { + let notes = store::all(&mut corpus.connection, None).expect("the corpus"); + let packed = bundle::collect(&mut corpus.connection, notes).expect("a bundle"); + black_box(file::write(&target, &packed).expect("a written file")); + }); + }); + + group.bench_function("import_notes, every id already there", |b| { + b.iter(|| { + let incoming = file::read(&target).expect("a readable file"); + black_box(bundle::merge(&mut corpus.connection, incoming).expect("a merge")); + }); + }); + + group.finish(); + let _ = std::fs::remove_file(&path); +} + +criterion_group!( + benches, + whole_corpus_read, + single_write, + bulk, + aggregation, + corpus_rewrite, + disk +); +criterion_main!(benches); diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs new file mode 100644 index 0000000..afc57a9 --- /dev/null +++ b/src-tauri/benches/corpus.rs @@ -0,0 +1,140 @@ +//! The corpus the benchmarks run against, and the shape of it is a decision. +//! +//! **800 notes of ~13 kB.** Not a round number picked for looks: it is the shape already +//! quoted in `notes/view.rs`, where the accent fold is measured at 6.3 ms and the +//! streaming NFD version at 76 ms. Numbers produced here are comparable to those, and +//! #30 needs the same fixture to defend them — whichever of the two lands first builds +//! it, and this is it. +//! +//! ⚠️ **File-backed, never `open_in_memory`.** The 295 tests use the in-memory database +//! and it would be natural to reuse it here. An in-memory database has no pager backed by +//! a file, no page cache doing real work and no I/O at all, so it measures something the +//! application never does. The numbers would be pleasant and useless. + +use std::path::PathBuf; + +use chrono::{DateTime, TimeDelta, Utc}; +use diesel::SqliteConnection; + +use devbox_lib::db; +use devbox_lib::notes::checklist::{ChecklistItem, NoteKind}; +use devbox_lib::notes::language::Language; +use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; +use devbox_lib::notes::store; +use devbox_lib::spaces::store as spaces; + +pub(crate) const NOTES: usize = 800; +/// Roughly 13 kB of body, the size the quoted measurements were taken at. +const LINES_PER_NOTE: usize = 200; +const SPACES: usize = 4; +const TAGS: &[&str] = &[ + "angular", "api", "ci", "db", "docker", "git", "ops", "rust", "sql", "ssh", +]; + +/// A database file of its own per run, dropped with the guard. +pub(crate) struct Corpus { + pub(crate) connection: SqliteConnection, + pub(crate) space_ids: Vec, + pub(crate) note_ids: Vec, + directory: PathBuf, +} + +impl Drop for Corpus { + fn drop(&mut self) { + // The WAL and the shm sit beside it; the whole directory goes. + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +pub(crate) fn now() -> DateTime { + db::iso8601::parse("2026-07-25T09:00:00.000Z").expect("a valid instant") +} + +/// ⚠️ Accented on purpose, and not decoration: search folds every byte it reads, and a +/// pure-ASCII corpus would measure the fast path only — which is the one branch of `fold` +/// that was never in question. +fn body(seed: usize) -> String { + let mut text = String::with_capacity(LINES_PER_NOTE * 70); + for line in 0..LINES_PER_NOTE { + text.push_str(match (seed + line) % 4 { + 0 => " let étape = migration::run(&mut connection)?; // déploiement\n", + 1 => " kubectl rollout restart deployment/api --namespace prod\n", + 2 => " SELECT calls, mean_exec_time FROM pg_stat_statements LIMIT 20;\n", + _ => " const keysOf = (value: T) => Object.keys(value);\n", + }); + } + text +} + +fn draft(seed: usize, space_id: &str) -> NoteDraft { + let checklist = seed.is_multiple_of(10); + + NoteDraft { + space_id: space_id.to_string(), + title: format!("Note {seed} — étape de déploiement"), + language: Language::Rs, + content: if checklist { String::new() } else { body(seed) }, + source: "Runbook".to_string(), + tags: vec![ + TAGS[seed % TAGS.len()].to_string(), + TAGS[(seed + 3) % TAGS.len()].to_string(), + ], + pinned: seed.is_multiple_of(50), + lifecycle: if seed.is_multiple_of(25) { + NoteLifecycle::Expires { + at: now() + TimeDelta::days(3), + } + } else { + NoteLifecycle::Permanent + }, + kind: if checklist { + NoteKind::Checklist + } else { + NoteKind::Snippet + }, + items: if checklist { + (0..5u32) + .map(|at| ChecklistItem { + text: format!("Étape {at} du déploiement"), + done: at.is_multiple_of(2), + }) + .collect() + } else { + Vec::new() + }, + } +} + +/// Seeded once per benchmark group. Writing 800 notes takes a few seconds, which is why +/// it is never inside a `b.iter`. +pub(crate) fn build() -> Corpus { + let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).expect("a writable temporary directory"); + + let mut connection = db::open(&directory.join("bench.sqlite3")).expect("a fresh database"); + + let space_ids: Vec = (0..SPACES) + .map(|at| { + spaces::create(&mut connection, &format!("Space {at}")) + .expect("a space") + .id + }) + .collect(); + + let at = now(); + let note_ids: Vec = (0..NOTES) + .map(|seed| { + let space_id = &space_ids[seed % SPACES]; + store::create(&mut connection, draft(seed, space_id), at) + .expect("a note") + .id + }) + .collect(); + + Corpus { + connection, + space_ids, + note_ids, + directory, + } +} From 317f1138f6511638dafc7495df24f1e210ee8f01 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 04:05:50 +0200 Subject: [PATCH 2/5] Take rustls past RUSTSEC-2026-0285 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLS 1.3 handshake messages were accepted across encryption level boundaries. The handshake transcript stays authenticated, so a network-position attacker cannot alter or complete one — the effect is that a peer could send in plaintext what should have been encrypted without the connection being rejected. Reached through tauri-plugin-updater, which is the only thing here that speaks TLS. Both packages named on one command: 0.23.45 wants webpki ^0.103.14 and the lockfile held 0.103.13, so updating rustls alone falls back to 0.23.43 — which is still below the fix — rather than move a sibling. Co-Authored-By: Claude Opus 5 --- src-tauri/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a11fef0..7871ba1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3715,9 +3715,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -3777,9 +3777,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From 28e9f0ba01e5a5c67ac8203c50b325937d2daa57 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 18:23:38 +0200 Subject: [PATCH 3/5] Strip Cargo.toml of its commentary Co-Authored-By: Claude Opus 5 --- src-tauri/Cargo.toml | 61 ++++++++++---------------------------------- 1 file changed, 14 insertions(+), 47 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1990762..baf07f0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "devbox" version = "0.1.4" -# ⚠️ Shipped: the installer and the Linux `.desktop` entry show this string. description = "A developer's notes and snippets manager for the desktop" authors = ["Valentin MILLET"] edition = "2024" @@ -10,13 +9,17 @@ repository = "https://github.com/vmillet-dev/devbox-rs" license = "GPL-3.0-only" readme = "../README.md" default-run = "devbox" -# ⚠️ Off, and the [[bench]] table below is why. Cargo would otherwise discover every -# benches/*.rs as a target of its own — including the shared corpus, which is a module -# and not a benchmark, and which clippy then reports as entirely unused. autobenches = false -# Both binaries are auto-discovered; these tables exist only to turn the built-in -# harness off, for the reason given on [lib]. +[package.metadata.devbox] +display-name = "DevBox" +author-handle = "@vmillet-dev" + +[lib] +name = "devbox_lib" +crate-type = ["staticlib", "cdylib", "rlib"] +bench = false + [[bin]] name = "devbox" path = "src/main.rs" @@ -27,21 +30,7 @@ name = "export-bindings" path = "src/bin/export-bindings.rs" bench = false -[package.metadata.devbox] -display-name = "DevBox" -author-handle = "@vmillet-dev" - -[lib] -name = "devbox_lib" -crate-type = ["staticlib", "cdylib", "rlib"] -# ⚠️ The lib carries no benchmarks, but cargo gives every target the built-in test -# harness by default and runs it first — which then rejects criterion's own flags with -# "Unrecognized option: save-baseline". -bench = false - [features] -# The end-to-end harness, and nothing else. Off by default, so a release build -# never links the test plugin into the shipped binary. e2e = ["dep:tauri-plugin-wdio", "dep:tauri-plugin-wdio-webdriver"] [build-dependencies] @@ -69,40 +58,19 @@ tauri-plugin-store = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-window-state = "2.4.1" tauri-plugin-log = "2" -log = "0.4" -thiserror = "2.0.20" -unicode-normalization = "0.1.25" -# Pulled in only by the `e2e` feature, and not optional in practice: the runner -# waits on `window.wdioTauri` before it will open a session, so a binary without -# the plugin hangs the suite rather than failing it. Its Rust half and the npm half -# (`polyfills` in the `e2e` Angular configuration) go together — with one but not -# the other, the front end invokes `plugin:wdio|…` commands nothing answers and the -# error banner comes up on launch. -# -# ⚠️ Both crates, not one: the documentation presents `-webdriver` as needed by the -# embedded driver provider alone, but it also serves the HTTP endpoint the runner's -# `DirectEvalClient` posts to. -tauri-plugin-wdio = { version = "1", optional = true } -tauri-plugin-wdio-webdriver = { version = "1", optional = true } - -# Desktop-only plugins. They are plain dependencies rather than a -# `cfg(not(android/ios))` table: DevBox targets the desktop, and a conditional table -# only pretended the crate could build anywhere else. tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" +tauri-plugin-wdio = { version = "1", optional = true } +tauri-plugin-wdio-webdriver = { version = "1", optional = true } +log = "0.4" +thiserror = "2.0.20" +unicode-normalization = "0.1.25" -# ⚠️ The first dev-dependency, and `deny.toml` sets `all-features = true`, so cargo-deny -# inspects this tree too: criterion's dependencies answer to the licence allow-list. -# -# criterion and not divan, for the one reason that decides it: `--save-baseline` and -# `--baseline`. A baseline is only worth keeping if a later state can be compared to it in -# one command. [dev-dependencies] criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } -# `harness = false`: criterion brings its own main. [[bench]] name = "commands" harness = false @@ -124,4 +92,3 @@ missing_errors_doc = "allow" missing_panics_doc = "allow" must_use_candidate = "allow" module_name_repetitions = "allow" - From 312cfeea04059283a7f2451a7da472552f9a50ec Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 18:23:38 +0200 Subject: [PATCH 4/5] Measure the commands on 8000 notes, and stop leaking the corpus query_notes costs 403 ms there, past its own 150 ms debounce, and list_tags is the only command that degrades faster than the corpus. Corpus dropped its temp directory while SQLite still held the file open, which Windows refuses to delete over: six groups leaked a corpus each. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- docs/architecture.md | 92 ++++++++++++++++++++--------------- src-tauri/benches/commands.rs | 24 ++++----- src-tauri/benches/corpus.rs | 42 ++++++++-------- 4 files changed, 85 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0f0f90..9945ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Run all commands from the repo root (`package.json` there wraps both Angular and - `npm run e2e:build` then `npm run test:e2e` — the end-to-end suite (WebdriverIO + `tauri-driver`), twelve scenarios under `e2e/specs/` against the **assembled** application. The build step is not optional after a change to `src/` or `src-tauri/`: the suite drives a binary with the front end compiled into it. -- `cargo bench` from `src-tauri/` — criterion, against a **file-backed** database of 800 notes of ~13 kB, below the command boundary. Deliberately **not in CI**: a timing assertion on a shared runner flaps. `--save-baseline main` then `--baseline main` is the comparison the harness exists for. ⚠️ `autobenches = false` and `bench = false` on the lib and both bins are load-bearing, and commented in `Cargo.toml`. The first baseline is in `docs/architecture.md`: `query_notes` costs **27 ms on 800 notes**, and the search is not what costs — filtering is cheaper than not filtering. +- `cargo bench` from `src-tauri/` — criterion, against a **file-backed** database of 8000 notes of ~13 kB (~104 MB, seeded once per group), below the command boundary. Deliberately **not in CI**, and not in `cargo test` either — cargo gives a `[[bench]]` target `test = false`, so the suite never builds it. `--save-baseline main` then `--baseline main` is the comparison the harness exists for, but that baseline lands in the gitignored `target/`: it is local to one machine and dies with `cargo clean`, so the durable record is the table in `docs/architecture.md`. ⚠️ `autobenches = false` and `bench = false` on the lib and both bins are load-bearing, and `Corpus` must not implement `Drop` itself — all three are explained in `docs/architecture.md` rather than in `Cargo.toml`. The baseline: `query_notes` costs **403 ms on 8000 notes**, which is past its own 150 ms debounce; the search still is not what costs; and `list_tags` is the only command that degrades faster than the corpus (×64 for ×10 the notes). - `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` forbids `unsafe_code`, denies `clippy::all` and warns on `clippy::pedantic`, `rust_2018_idioms` and `unreachable_pub`. The toolchain is pinned in `rust-toolchain.toml`, so a new stable release can't turn CI red on an untouched commit. diff --git a/docs/architecture.md b/docs/architecture.md index 59e2c8c..096eb69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2022,54 +2022,70 @@ cargo bench -- --baseline main # after ``` That comparison is the whole reason the harness is **criterion** and not divan, which is -lighter and pleasanter but does not offer it out of the box. +lighter and pleasanter but does not offer it out of the box. ⚠️ The baseline it writes lives +in `src-tauri/target/criterion/`, which is gitignored: it is local to one machine and dies +with `cargo clean`. The table below is the durable record. ⚠️ **They run below the command boundary, not through Tauri.** A command is four lines — validate, lock, delegate, translate the error — so `store::*` plus `view::*` plus the serde round-trip captures nearly all of the cost. `tauri::test::mock_app` would drag the whole app lifecycle in and buy only the IPC transport, which this codebase does not control. So these numbers are **not** "the IPC is fast": they are what the work behind a command costs. -Serialisation is included on purpose — a `NotesView` over 800 notes is a real `serde_json` +Serialisation is included on purpose — a `NotesView` over 8000 notes is a real `serde_json` cost paid on every keystroke. ⚠️ **The corpus is file-backed, never `open_in_memory`.** An in-memory database has no pager behind a file, no page cache doing real work and no I/O at all — it measures something the -application never does. `benches/corpus.rs` writes 800 notes of ~13 kB into a temporary file -database, the shape already quoted in `notes/view.rs`, and its bodies are accented on -purpose: a pure-ASCII corpus would exercise only `fold`'s fast path. - -Two Cargo details exist solely to make this work, both commented in `Cargo.toml`: -`autobenches = false` (or the shared corpus module is discovered as a benchmark of its own -and reported as entirely unused) and `bench = false` on the lib and both bins (or cargo runs -their built-in harness first, which rejects criterion's own flags). - -#### The first baseline - -Taken on 800 notes, Windows, release profile with `lto = true`: - -| Command | Cost | -| ---------------------------------------------- | ---------------- | -| `query_notes`, unfiltered | **27.3 ms** | -| `query_notes`, search matching nothing | 26.8 ms | -| `query_notes`, search folding accents | 25.8 ms | -| `delete_notes` then `restore_notes`, 100 notes | 9.0 ms | -| `export_notes` / `import_notes` | 26.7 ms / 8.3 ms | -| `rename_tag` across the corpus | 3.8 ms | -| `move_notes` / `tag_notes`, 100 notes | 2.7 ms / 2.3 ms | -| `update_note` | 1.5 ms | -| `list_tags` / `list_trash` | 591 µs / 424 µs | -| `list_global_placeholders` | 1.6 µs | - -Two things worth reading off that table. - -**`query_notes` costs 27 ms and runs on every keystroke**, behind the 150 ms debounce. It is -comfortably inside the debounce at this size, and it is the number #21 is about: the cost is -linear in the corpus, so five thousand notes would put it past a tenth of a second. - -**The search is not what costs.** Filtering is _slightly cheaper_ than not filtering — fewer -notes to serialise — so the accent fold is not the dominant term at all. Fetching 800 × 13 kB -out of SQLite and turning the view into JSON is. Work aimed at making search faster would be -aimed at the wrong half. +application never does. `benches/corpus.rs` writes 8000 notes of ~13 kB into a temporary file +database — about 104 MB, seeded once per group — and its bodies are accented on purpose: a +pure-ASCII corpus would exercise only `fold`'s fast path. + +⚠️ **`Corpus` does not implement `Drop`; its `TempDir` field does, and is declared last.** +Fields drop in declaration order but a `Drop` on the struct runs before all of them, so the +erasure fired while SQLite still held the file open — which Windows refuses to delete over, +and `let _ =` swallowed the error. Six groups leaked a corpus each, 600 MB a run. + +Two Cargo details exist solely to make this work, and they are documented here rather than in +`Cargo.toml`: `autobenches = false` (or the shared corpus module is discovered as a benchmark +of its own and reported as entirely unused) and `bench = false` on the lib and both bins (or +cargo runs their built-in harness first, which rejects criterion's own flags). + +#### The baseline + +8000 notes, Windows, release profile with `lto = true`. The last column is the same benchmark +against the 800-note corpus this suite started on, which is what says whether a cost is linear +in the corpus or worse. + +| Command | Cost | 800 → 8000 | +| ---------------------------------------------- | --------------- | ------------- | +| `query_notes`, search matching nothing | 406 ms | ×15.1 | +| `query_notes`, unfiltered | **403 ms** | ×14.7 | +| `query_notes`, search folding accents | 392 ms | ×15.2 | +| `export_notes` / `import_notes` | 377 ms / 98 ms | ×14.1 / ×11.8 | +| `list_tags` | 37.9 ms | **×64** | +| `delete_notes` then `restore_notes`, 100 notes | 10.4 ms | ×1.2 | +| `rename_tag` across the corpus | 8.2 ms | ×2.2 | +| `move_notes` / `tag_notes`, 100 notes | 5.1 ms / 4.2 ms | ×1.9 / ×1.8 | +| `list_trash` | 4.5 ms | ×10.5 | +| `update_note` | 1.5 ms | ×1.0 | +| `list_global_placeholders` | 1.5 µs | ×1.0 | + +Three things worth reading off that table. + +**`query_notes` has gone past the debounce.** It runs on every keystroke behind a 150 ms +debounce, and at 800 notes its 27 ms sat comfortably inside it. At 8000 it costs 403 ms: the +query fired for one keystroke is still running when the third one after it arrives. That is +what #21 is about, and this is the number that says the problem has stopped being theoretical. + +**The search is still not what costs.** Folding accents is the _cheapest_ of the three +variants (392 ms against 403 ms unfiltered) — fewer notes survive to be serialised. Fetching +8000 × 13 kB out of SQLite and turning the view into JSON is the whole cost, and work aimed at +making the match faster would be aimed at the wrong half. + +**`list_tags` is the one that degrades faster than the corpus.** ×64 for ×10 the data, the only +entry on the table that is markedly super-linear, and invisible at 800 notes where it cost +591 µs. It joins `note_tags` to `notes` to read one nullable column, so each of the 16 000 tag +rows dereferences a ~13 kB note row; the notes table was 10 MB at 800 notes and is ~104 MB at 8000. ⚠️ That is the likely cause and it is **not confirmed** — no query plan was taken. Unit tests run with Vitest through the `@angular/build:unit-test` builder in a jsdom environment (configured in `angular.json`'s `test` target and `vitest-base.config.ts`), so diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs index 7307b9a..4996d9a 100644 --- a/src-tauri/benches/commands.rs +++ b/src-tauri/benches/commands.rs @@ -1,21 +1,15 @@ //! What the IPC surface costs, by class of command. //! -//! ⚠️ **Below the command boundary, not through Tauri.** A command is meant to be four -//! lines — validate, lock, delegate, translate the error — and that rule holds across the -//! surface, so `store::*` plus `view::*` plus the serde round-trip of the payload captures -//! nearly all of the cost. `tauri::test::mock_app` exists, but dragging the whole app -//! lifecycle in buys only the IPC transport, which this codebase does not control. +//! ⚠️ **Below the command boundary, not through Tauri.** A command is four lines — validate, +//! lock, delegate, translate the error — so `store::*` plus `view::*` plus the serde round +//! trip captures nearly all of the cost. `tauri::test::mock_app` would drag the whole app +//! lifecycle in and buy only the IPC transport, which this codebase does not control. So +//! these numbers are not "the IPC is fast": they are what the work behind a command costs. //! -//! **So these numbers are not "the IPC is fast".** They are what the work behind a command -//! costs. Serialisation *is* included and deliberately so: a `NotesView` over 800 notes is -//! a real `serde_json` cost paid on every keystroke, and it belongs in the number. -//! -//! The three commands in `desktop.rs` are out of scope: they touch no database and stay on -//! the main thread on purpose. +//! The three commands in `desktop.rs` are out of scope: they touch no database. //! //! ``` //! cargo bench -- --save-baseline main -//! # … whatever changes … //! cargo bench -- --baseline main //! ``` @@ -64,6 +58,9 @@ fn run_query(corpus: &mut Corpus, request: &NotesQuery) -> String { fn whole_corpus_read(c: &mut Criterion) { let mut corpus = build(); let mut group = c.benchmark_group("whole-corpus read"); + // A pass over 8000 notes is long enough that criterion's hundred samples would make + // this group most of the run. + group.sample_size(20); group.bench_function("query_notes, unfiltered", |b| { b.iter(|| black_box(run_query(&mut corpus, &query("")))); @@ -195,8 +192,7 @@ fn corpus_rewrite(c: &mut Criterion) { fn disk(c: &mut Criterion) { let mut corpus = build(); let mut group = c.benchmark_group("disk"); - // 800 notes of 13 kB is a ~10 MB bundle: long enough that these two want their own - // sample size rather than criterion's hundred. + // The bundle is ~100 MB, so these two want their own sample size. group.sample_size(10); let path = std::env::temp_dir().join("devbox-bench-export.json"); diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs index afc57a9..89da3f1 100644 --- a/src-tauri/benches/corpus.rs +++ b/src-tauri/benches/corpus.rs @@ -1,15 +1,10 @@ -//! The corpus the benchmarks run against, and the shape of it is a decision. +//! The corpus the benchmarks run against. //! -//! **800 notes of ~13 kB.** Not a round number picked for looks: it is the shape already -//! quoted in `notes/view.rs`, where the accent fold is measured at 6.3 ms and the -//! streaming NFD version at 76 ms. Numbers produced here are comparable to those, and -//! #30 needs the same fixture to defend them — whichever of the two lands first builds -//! it, and this is it. +//! **8000 notes of ~13 kB**, the shape quoted in `notes/view.rs` taken to the size #21 is +//! about — a corpus big enough that a linear cost shows up as one. //! -//! ⚠️ **File-backed, never `open_in_memory`.** The 295 tests use the in-memory database -//! and it would be natural to reuse it here. An in-memory database has no pager backed by -//! a file, no page cache doing real work and no I/O at all, so it measures something the -//! application never does. The numbers would be pleasant and useless. +//! ⚠️ **File-backed, never `open_in_memory`.** An in-memory database has no pager behind a +//! file, no page cache and no I/O, so it measures something the application never does. use std::path::PathBuf; @@ -23,26 +18,31 @@ use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; use devbox_lib::notes::store; use devbox_lib::spaces::store as spaces; -pub(crate) const NOTES: usize = 800; -/// Roughly 13 kB of body, the size the quoted measurements were taken at. +pub(crate) const NOTES: usize = 8000; +/// Roughly 13 kB of body. const LINES_PER_NOTE: usize = 200; const SPACES: usize = 4; const TAGS: &[&str] = &[ "angular", "api", "ci", "db", "docker", "git", "ops", "rust", "sql", "ssh", ]; -/// A database file of its own per run, dropped with the guard. +/// A database file of its own per group, erased with the guard. pub(crate) struct Corpus { pub(crate) connection: SqliteConnection, pub(crate) space_ids: Vec, pub(crate) note_ids: Vec, - directory: PathBuf, + /// ⚠️ Last, and the erasure lives on this field rather than on `Corpus`: fields drop + /// in declaration order, but a `Drop` on the struct runs *before* all of them — with + /// the connection still open, which Windows refuses to delete over. + _directory: TempDir, } -impl Drop for Corpus { +struct TempDir(PathBuf); + +impl Drop for TempDir { fn drop(&mut self) { // The WAL and the shm sit beside it; the whole directory goes. - let _ = std::fs::remove_dir_all(&self.directory); + let _ = std::fs::remove_dir_all(&self.0); } } @@ -50,9 +50,8 @@ pub(crate) fn now() -> DateTime { db::iso8601::parse("2026-07-25T09:00:00.000Z").expect("a valid instant") } -/// ⚠️ Accented on purpose, and not decoration: search folds every byte it reads, and a -/// pure-ASCII corpus would measure the fast path only — which is the one branch of `fold` -/// that was never in question. +/// ⚠️ Accented on purpose: `fold` has a fast path for pure ASCII, and a corpus that only +/// took it would measure the one branch that was never in question. fn body(seed: usize) -> String { let mut text = String::with_capacity(LINES_PER_NOTE * 70); for line in 0..LINES_PER_NOTE { @@ -105,8 +104,7 @@ fn draft(seed: usize, space_id: &str) -> NoteDraft { } } -/// Seeded once per benchmark group. Writing 800 notes takes a few seconds, which is why -/// it is never inside a `b.iter`. +/// Seeded once per benchmark group, never inside a `b.iter`. pub(crate) fn build() -> Corpus { let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&directory).expect("a writable temporary directory"); @@ -135,6 +133,6 @@ pub(crate) fn build() -> Corpus { connection, space_ids, note_ids, - directory, + _directory: TempDir(directory), } } From 8c005edf73a9115d0acfb60184de87ac66cd90e4 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 18:23:38 +0200 Subject: [PATCH 5/5] Cut cargo-deny down to the advisories licenses, bans and sources never made a decision in 25 runs. Advisories stay: Dependabot alerts are off, so this is the only net there is. Co-Authored-By: Claude Opus 5 --- .github/workflows/security.yml | 11 ++++--- src-tauri/deny.toml | 53 +--------------------------------- 2 files changed, 8 insertions(+), 56 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a0694ee..f584f08 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,7 +28,7 @@ permissions: jobs: cargo-deny: - name: Cargo advisories and licenses + name: Cargo advisories runs-on: ubuntu-22.04 timeout-minutes: 10 steps: @@ -36,12 +36,15 @@ jobs: # Reads `Cargo.lock` and the advisory database; it compiles nothing, which is # why this job needs no Rust toolchain and no cargo cache. + # + # ⚠️ `advisories` alone, and it is the repository's only advisory net — Dependabot + # alerts are off. `licenses`, `bans` and `sources` stood here and never made a + # decision in 25 runs; the GPL-3.0 compatibility of the tree is a release-time + # review, not a per-pull-request gate. - uses: EmbarkStudios/cargo-deny-action@v2 with: manifest-path: src-tauri/Cargo.toml - # `licenses` is not decoration: DevBox ships under GPL-3.0-only, and a - # dependency that is not compatible with it cannot be distributed. - command: check advisories bans licenses sources + command: check advisories npm-audit: name: npm advisories diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index 4517bbc..1bcc924 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -1,65 +1,14 @@ [graph] -# The release matrix, plus macOS so a contributor working there is covered too. targets = [ "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu", "aarch64-apple-darwin", ] -# Reaches the `e2e` feature's dependencies, which nothing else inspects. all-features = true [advisories] -# Only the crates this project actually chose. An unmaintained crate five levels -# down is not something a pull request here can act on. unmaintained = "workspace" -# "all", not "workspace": unsoundness in a transitive crate still runs in our -# process. The one exception below is named rather than the policy weakened, so a -# second one fails this job. unsound = "all" ignore = [ - # `glib::VariantStrIter` — Linux only, and immovable from here: 0.18.5 is the - # last 0.18.x, pinned by `gtk 0.18.2`, which Tauri v2.11 pins in turn, and the - # fix landed in glib 0.20. DevBox never calls glib, let alone the five affected - # `VariantStrIter` methods; the crate arrives under the GTK/WebKitGTK stack and - # is absent from the Windows tree entirely. - # - # ⚠️ Drop this line when Tauri moves to gtk-rs 0.20+ — keeping it afterwards - # would hide a real finding. - { id = "RUSTSEC-2024-0429", reason = "glib 0.18 is pinned by Tauri's gtk-rs 0.18; unreachable from our code" }, + { id = "RUSTSEC-2024-0429", reason = "glib 0.18 is pinned by Tauri's gtk-rs 0.18 and unreachable from our code; drop this when Tauri moves to gtk-rs 0.20+" }, ] - -[licenses] -confidence-threshold = 0.93 -# ⚠️ DevBox ships under GPL-3.0-only, so every dependency has to be -# GPL-3.0-compatible. This list is the inventory of what the tree actually -# carries — all permissive, MPL-2.0 included (compatible under its section 3.3). -# A new entry means a real decision, not a formality: check the compatibility -# before adding one. -allow = [ - "0BSD", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "GPL-3.0-only", - "ISC", - "MIT", - "MIT-0", - "MPL-2.0", - "Unicode-3.0", - "Unlicense", - "Zlib", -] -exceptions = [] - -[bans] -# A Tauri tree always carries a few duplicate versions it does not control; -# denying them would make this job red for something no contributor can fix. -multiple-versions = "allow" -wildcards = "deny" - -[sources] -unknown-registry = "deny" -unknown-git = "deny"