Browse / github.com/storytold/soundcraft / patch
VST3: cache scans on disk and scan without freezing the UI
VST3: cache scans on disk and scan without freezing the UI Every launch loaded every VST3 binary just to list its classes. That takes seconds with a large plugin folder, and runs every plugin's static initialisers whether it's used or not. The scan also ran inside a single UI frame the first time the VST3 menu opened, freezing the window. ## What changes - **A scan cache on disk.** The app sets `vst3-cache.json` in its config folder (`set_cache_file`; not when `SOUNDCRAFT_NO_PREFS` is set). It stores each bundle's classes, or why it wouldn't load, keyed by path plus its binary's size and modification time. An unchanged bundle is listed without l
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- mrrobbiec (imported from pull request #806, open)
- Base
25d52c646aea780c8076ff966076f994ad256221- Size
- +408 −12 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
175b4e43487af481598eeb64
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock apps/soundcraft/Cargo.toml apps/soundcraft/src/main.rs crates/ui-egui/src/mix_window.rs crates/vst3-host/Cargo.toml crates/vst3-host/src/cache.rs crates/vst3-host/src/lib.rs crates/vst3-host/src/registry.rs
@@ -3585,6 +3585,7 @@ dependencies = [ "soundcraft-engine", "soundcraft-playback", "soundcraft-ui-egui",+ "soundcraft-vst3-host", "winresource", ] @@ -3789,6 +3790,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde",+ "serde_json", "soundcraft-dsp", "thiserror 2.0.21", "vst3",@@ -18,6 +18,7 @@ asio = ["soundcraft-playback/asio"] soundcraft-engine.workspace = true soundcraft-ui-egui.workspace = true soundcraft-playback.workspace = true+soundcraft-vst3-host.workspace = true eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } egui.workspace = true serde_json.workspace = true@@ -245,6 +245,13 @@ fn main() -> eframe::Result { } } + // Unchanged plugins are then listed from disk instead of loading every binary on each launch.+ if prefs_enabled()+ && let Some(dir) = config_dir()+ {+ soundcraft_vst3_host::set_cache_file(&dir.join("vst3-cache.json"));+ }+ let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; let prefs = load_prefs().unwrap_or_default(); let mut options = eframe::NativeOptions {@@ -759,12 +759,15 @@ fn plugin_search(ui: &mut Ui, search: &MenuSearch, instruments: bool) -> Option< } if clap.is_none() || vst3.is_none() { ui.separator();+ if vst3.is_none() && soundcraft_vst3_host::scan_in_progress() {+ vst3_scan_status(ui);+ } ui.horizontal(|ui| { if clap.is_none() && ui.small_button("Scan CLAP").clicked() { soundcraft_clap_host::scan(); }- if vst3.is_none() && ui.small_button("Scan VST3").clicked() {- soundcraft_vst3_host::scan();+ if vst3.is_none() && !soundcraft_vst3_host::scan_in_progress() && ui.small_button("Scan VST3").clicked() {+ vst3_scan_status(ui); } }); }@@ -851,6 +854,25 @@ struct MenuPlugin { vendor: String, } +/// UI time given to the VST3 scan per frame. Bundles the scan cache knows cost a file stat; a+/// new or changed one is loaded (at most one per frame), which can take a second or more.+const VST3_SCAN_BUDGET: std::time::Duration = std::time::Duration::from_millis(30);++/// Advances the VST3 scan a step (starting it if needed) and, while it runs, shows its progress+/// and keeps frames coming. True once the list is ready. The scan stays on the UI thread: plugin+/// modules adopt the thread that loads them, and their editors must open on this one.+fn vst3_scan_status(ui: &mut Ui) -> bool {+ match soundcraft_vst3_host::scan_step(VST3_SCAN_BUDGET) {+ soundcraft_vst3_host::ScanProgress::Done => true,+ soundcraft_vst3_host::ScanProgress::Scanning { done, total, next } => {+ ui.label(format!("Scanning VST3 plugins… {done} of {total}"));+ ui.weak(next);+ ui.ctx().request_repaint();+ false+ }+ }+}+ /// The CLAP, VST3 and (on macOS) Audio Units submenus, of effects or of instruments; returns the /// id picked. Each format is scanned the first time its submenu opens, then cached by its host. fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option<String> {@@ -862,7 +884,10 @@ fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option<String> { } }); ui.menu_button("VST3", |ui| {- let list = soundcraft_vst3_host::scan().into_iter().filter(|d| d.is_instrument == instruments);+ if !vst3_scan_status(ui) {+ return;+ }+ let list = soundcraft_vst3_host::cached().unwrap_or_default().into_iter().filter(|d| d.is_instrument == instruments); if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No VST3 plugins found") { picked = Some(p); }@@ -10,6 +10,7 @@ repository.workspace = true [dependencies] soundcraft-dsp.workspace = true serde.workspace = true+serde_json.workspace = true thiserror.workspace = true log.workspace = true new file mode 100644@@ -0,0 +1,137 @@+//! The on-disk scan cache: what each `.vst3` held the last time it was loaded, keyed by the+//! bundle path plus its binary's size and modification time. A bundle whose binary is unchanged+//! is listed from the cache without loading it, so a launch with a cache loads no plugin code+//! until a plugin is actually used. Bundles that failed to load are remembered too (not retried+//! until their binary changes or the user rescans). Safe, pure `std::fs` + serde.++use crate::Vst3Descriptor;+use std::collections::HashMap;+use std::path::{Path, PathBuf};+use std::time::UNIX_EPOCH;++/// Bumped whenever the entry layout or the meaning of a descriptor changes; other versions are ignored.+const VERSION: u32 = 1;+/// Largest cache file read (a hostile or corrupt file must not exhaust memory).+const MAX_BYTES: u64 = 16 * 1024 * 1024;++/// The size and modification time of a bundle's binary (what decides whether it changed).+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+pub struct Stamp {+ pub size: u64,+ pub modified_ns: u64,+}++impl Stamp {+ /// The stamp of `bundle`'s binary; `None` when it has none or cannot be read.+ pub fn of(bundle: &Path) -> Option<Stamp> {+ let exe = crate::scan::bundle_binary(bundle)?;+ let md = std::fs::metadata(exe).ok()?;+ let modified = md.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;+ Some(Stamp { size: md.len(), modified_ns: u64::try_from(modified.as_nanos()).unwrap_or(u64::MAX) })+ }+}++/// One bundle as it was last loaded: its plugin classes, or why it would not load.+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Entry {+ pub path: PathBuf,+ pub stamp: Stamp,+ pub descriptors: Vec<Vst3Descriptor>,+ pub error: Option<String>,+}++#[derive(serde::Serialize, serde::Deserialize)]+struct File {+ version: u32,+ entries: Vec<Entry>,+}++/// Reads a cache file; empty when it is missing, too big, unreadable or another version.+pub fn read(path: &Path) -> HashMap<PathBuf, Entry> {+ let ok = std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_BYTES);+ let parsed = ok.then(|| std::fs::read(path).ok()).flatten().and_then(|b| serde_json::from_slice::<File>(&b).ok());+ match parsed {+ Some(f) if f.version == VERSION => f.entries.into_iter().map(|e| (e.path.clone(), e)).collect(),+ _ => HashMap::new(),+ }+}++/// Writes the cache atomically (a temporary file renamed over the old one).+pub fn write(path: &Path, entries: Vec<Entry>) -> std::io::Result<()> {+ if let Some(dir) = path.parent() {+ std::fs::create_dir_all(dir)?;+ }+ let json = serde_json::to_vec_pretty(&File { version: VERSION, entries }).map_err(std::io::Error::other)?;+ let tmp = path.with_extension("json.tmp");+ std::fs::write(&tmp, json)?;+ std::fs::rename(&tmp, path)+}++#[cfg(test)]+mod tests {+ use super::*;+ use soundcraft_dsp::Category;++ fn tmp(name: &str) -> PathBuf {+ let d = std::env::temp_dir().join(format!("sc-vst3-cache-{name}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ std::fs::create_dir_all(&d).unwrap();+ d+ }++ fn entry(path: &Path) -> Entry {+ Entry {+ path: path.to_path_buf(),+ stamp: Stamp { size: 3, modified_ns: 7 },+ descriptors: vec![Vst3Descriptor {+ id: "vst3:00".into(),+ class_id: "00".into(),+ name: "Bass".into(),+ vendor: "V".into(),+ version: "1".into(),+ sdk_version: "VST 3.7".into(),+ sub_categories: vec!["Instrument".into()],+ category: Category::Instrument,+ is_instrument: true,+ path: path.to_path_buf(),+ }],+ error: None,+ }+ }++ #[test]+ fn round_trips() {+ let d = tmp("rt");+ let f = d.join("sub").join("vst3-cache.json");+ let e = entry(&d.join("Bass.vst3"));+ write(&f, vec![e.clone()]).unwrap();+ let back = read(&f);+ assert_eq!(back.get(&e.path), Some(&e));+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn junk_missing_and_other_versions_read_empty() {+ let d = tmp("junk");+ assert!(read(&d.join("missing.json")).is_empty());+ std::fs::write(d.join("junk.json"), b"{not json").unwrap();+ assert!(read(&d.join("junk.json")).is_empty());+ std::fs::write(d.join("v0.json"), br#"{"version":0,"entries":[]}"#).unwrap();+ assert!(read(&d.join("v0.json")).is_empty());+ assert!(read(&d).is_empty(), "a directory is not a cache file");+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn stamp_follows_the_binary() {+ let d = tmp("stamp");+ let b = d.join("one.vst3");+ assert_eq!(Stamp::of(&b), None);+ std::fs::write(&b, b"abc").unwrap();+ let s = Stamp::of(&b).unwrap();+ assert_eq!(s.size, 3);+ std::fs::write(&b, b"abcdef").unwrap();+ assert_ne!(Stamp::of(&b), Some(s));+ let _ = std::fs::remove_dir_all(&d);+ }+}@@ -43,6 +43,8 @@ pub mod scan; +#[cfg(not(target_arch = "wasm32"))]+mod cache; #[cfg(not(target_arch = "wasm32"))] #[allow(unsafe_code)] mod ffi;@@ -56,6 +58,7 @@ pub use plugin::Vst3Plugin; use soundcraft_dsp::{Category, Plugin, PluginInfo}; use std::path::{Path, PathBuf};+use std::time::Duration; /// Prefix of every hosted-VST3 plugin id. pub const ID_PREFIX: &str = "vst3:";@@ -78,7 +81,7 @@ pub fn parse_id(id: &str) -> Option<[u8; 16]> { } /// One plugin found by a scan.-#[derive(Debug, Clone, PartialEq, serde::Serialize)]+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Vst3Descriptor { /// SoundCraft id: `vst3:<class_id>`. pub id: String,@@ -112,6 +115,15 @@ pub enum Vst3Error { Unsupported, } +/// Where a scan advanced with [`scan_step`] has got to.+#[derive(Debug, Clone, PartialEq, Eq)]+pub enum ScanProgress {+ /// The list is ready ([`cached`] returns it).+ Done,+ /// `done` of `total` bundles listed; `next` is the one being listed next.+ Scanning { done: usize, total: usize, next: String },+}+ #[cfg(not(target_arch = "wasm32"))] mod api { use super::*;@@ -125,6 +137,15 @@ mod api { pub fn rescan() -> Vec<Vst3Descriptor> { registry::rescan() }+ pub fn scan_step(budget: Duration) -> ScanProgress {+ registry::scan_step(budget)+ }+ pub fn scan_in_progress() -> bool {+ registry::scan_in_progress()+ }+ pub fn set_cache_file(path: &Path) {+ registry::set_cache_file(path)+ } pub fn add_search_dir(dir: &Path) -> usize { registry::add_search_dir(dir) }@@ -158,6 +179,13 @@ mod api { pub fn rescan() -> Vec<Vst3Descriptor> { Vec::new() }+ pub fn scan_step(_budget: Duration) -> ScanProgress {+ ScanProgress::Done+ }+ pub fn scan_in_progress() -> bool {+ false+ }+ pub fn set_cache_file(_path: &Path) {} pub fn add_search_dir(_dir: &Path) -> usize { 0 }@@ -177,17 +205,38 @@ mod api { } /// Every plugin in the standard VST3 folders, `VST3_PATH` and folders added with-/// [`add_search_dir`]. Scanned on first call (loads each binary and reads its factory, but-/// creates no plugin instances), then cached in memory.+/// [`add_search_dir`]. Scanned on first call (loads each new or changed binary and reads its+/// factory, but creates no plugin instances; see [`set_cache_file`]), then cached in memory. pub fn scan() -> Vec<Vst3Descriptor> { api::scan() } -/// Forgets the cached scan (and remembered failures) and scans again.+/// Forgets the cached scan (and remembered failures) and scans again, loading every bundle+/// (the disk cache is rewritten, not consulted). pub fn rescan() -> Vec<Vst3Descriptor> { api::rescan() } +/// Advances the scan without blocking for long, for a UI that must keep drawing: bundles the+/// disk cache knows (unchanged binary) are listed without loading them; at most one bundle that+/// has to be loaded is loaded per call once any work is done. Starts the scan if needed. Call it+/// on the main thread: modules adopt the thread that loads them (JUCE ties its message thread to+/// it), so loading on a worker would break their editors later.+pub fn scan_step(budget: Duration) -> ScanProgress {+ api::scan_step(budget)+}++/// Whether a scan started by [`scan_step`] is still under way.+pub fn scan_in_progress() -> bool {+ api::scan_in_progress()+}++/// Caches scans in `path` (JSON): later scans list unchanged bundles without loading them, and+/// skip bundles that failed to load until they change. Call once at startup.+pub fn set_cache_file(path: &Path) {+ api::set_cache_file(path)+}+ /// Adds a folder to the search path and merges its plugins into the cache. Returns how many /// plugins the folder holds. pub fn add_search_dir(dir: &Path) -> usize {@@ -1,13 +1,15 @@ //! The process-wide VST3 registry: scan cache, leaked `PluginInfo`s, instantiation. +use crate::cache::{self, Entry, Stamp}; use crate::ffi::{Bundle, Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, RawParam}; use crate::plugin::Vst3Plugin; use crate::scan as paths;-use crate::{AUDIO_MODULE_CLASS, Vst3Descriptor, Vst3Error, parse_id};+use crate::{AUDIO_MODULE_CLASS, ScanProgress, Vst3Descriptor, Vst3Error, parse_id}; use soundcraft_dsp::{ParamInfo, Plugin, PluginInfo, Taper, Unit};-use std::collections::{HashMap, HashSet};+use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock, PoisonError};+use std::time::{Duration, Instant}; /// Most labels read for a stepped parameter (more steps are shown as a plain range). const MAX_CHOICES: i32 = 128;@@ -21,6 +23,81 @@ struct Registry { infos: HashMap<String, &'static PluginInfo>, /// Ids that failed to instantiate (not retried every UI frame). failed: HashSet<String>,+ /// Where the scan cache lives (`None`: scans are not cached on disk).+ cache_file: Option<PathBuf>,+ /// The scan under way, advanced a bundle at a time by [`scan_step`].+ job: Option<ScanJob>,+}++/// A scan in progress: the bundles still to list and what the finished ones held.+struct ScanJob {+ pending: VecDeque<PathBuf>,+ total: usize,+ /// The cache read at the start (empty when rescanning: every bundle is loaded again).+ known: HashMap<PathBuf, Entry>,+ found: Vec<Vst3Descriptor>,+ entries: Vec<Entry>,+}++impl ScanJob {+ fn new(dirs: &[PathBuf], known: HashMap<PathBuf, Entry>) -> ScanJob {+ let pending: VecDeque<PathBuf> = paths::find_bundles(dirs).into();+ ScanJob { total: pending.len(), pending, known, found: Vec::new(), entries: Vec::new() }+ }++ /// Adds `bundles` the scan has not listed or queued yet (a folder added while it runs).+ fn include(&mut self, bundles: Vec<PathBuf>) {+ for b in bundles {+ if !self.pending.contains(&b) && !self.entries.iter().any(|e| e.path == b) {+ self.pending.push_back(b);+ self.total += 1;+ }+ }+ }++ fn progress(&self) -> ScanProgress {+ let next = self.pending.front().and_then(|p| p.file_stem()).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ ScanProgress::Scanning { done: self.total.saturating_sub(self.pending.len()), total: self.total, next }+ }++ /// Lists bundles until `budget` is spent; a bundle that must be loaded (new, changed or never+ /// cached) is the slow part, so at most one is loaded per call once any work has been done.+ fn step(&mut self, budget: Duration) {+ let start = Instant::now();+ let mut loaded = false;+ while let Some(path) = self.pending.front().cloned() {+ let stamp = Stamp::of(&path);+ let cached = stamp.and_then(|st| self.known.get(&path).filter(|e| e.stamp == st)).cloned();+ if cached.is_none() && loaded {+ break;+ }+ self.pending.pop_front();+ let entry = match (cached, stamp) {+ (Some(e), _) => e,+ (None, stamp) => {+ loaded = true;+ let (descriptors, error) = match load_bundle(&path) {+ Ok(ds) => (ds, None),+ Err(e) => {+ log::warn!("{e}");+ (Vec::new(), Some(e.to_string()))+ }+ };+ let stamp = stamp.unwrap_or(Stamp { size: 0, modified_ns: 0 });+ Entry { path: path.clone(), stamp, descriptors, error }+ }+ };+ for d in &entry.descriptors {+ if !self.found.iter().any(|o| o.id == d.id) {+ self.found.push(d.clone());+ }+ }+ self.entries.push(entry);+ if start.elapsed() >= budget {+ break;+ }+ }+ } } fn registry() -> &'static Mutex<Registry> {@@ -73,11 +150,46 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> { out } +fn search_dirs(r: &Registry) -> Vec<PathBuf> {+ let mut dirs = paths::default_search_paths();+ dirs.extend(r.extra_dirs.iter().cloned());+ dirs+}++/// Starts a scan (unless one is under way): from the disk cache, or loading every bundle when `fresh`.+fn start_job(r: &mut Registry, fresh: bool) {+ if r.job.is_none() {+ let known = match (&r.cache_file, fresh) {+ (Some(f), false) => cache::read(f),+ _ => HashMap::new(),+ };+ r.job = Some(ScanJob::new(&search_dirs(r), known));+ }+}++/// Advances the scan; when it has finished, publishes the list and saves the cache.+fn advance(r: &mut Registry, budget: Duration) -> ScanProgress {+ let Some(job) = r.job.as_mut() else { return ScanProgress::Done };+ job.step(budget);+ if !job.pending.is_empty() {+ return job.progress();+ }+ let Some(job) = r.job.take() else { return ScanProgress::Done };+ let mut found = job.found;+ found.sort_by_key(|d| d.name.to_lowercase());+ r.scanned = Some(found);+ if let Some(f) = &r.cache_file+ && let Err(e) = cache::write(f, job.entries)+ {+ log::warn!("vst3: cannot save the scan cache {}: {e}", f.display());+ }+ ScanProgress::Done+}+ fn ensure_scanned(r: &mut Registry) -> &Vec<Vst3Descriptor> { if r.scanned.is_none() {- let mut dirs = paths::default_search_paths();- dirs.extend(r.extra_dirs.iter().cloned());- r.scanned = Some(scan_paths(&dirs));+ start_job(r, false);+ while advance(r, Duration::MAX) != ScanProgress::Done {} } r.scanned.get_or_insert_with(Vec::new) }@@ -94,10 +206,29 @@ pub fn cached() -> Option<Vec<Vst3Descriptor>> { pub fn rescan() -> Vec<Vst3Descriptor> { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None;+ r.job = None; r.failed.clear();+ start_job(&mut r, true); ensure_scanned(&mut r).clone() } +pub fn scan_step(budget: Duration) -> ScanProgress {+ let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+ if r.scanned.is_some() {+ return ScanProgress::Done;+ }+ start_job(&mut r, false);+ advance(&mut r, budget)+}++pub fn scan_in_progress() -> bool {+ registry().lock().unwrap_or_else(PoisonError::into_inner).job.is_some()+}++pub fn set_cache_file(path: &Path) {+ registry().lock().unwrap_or_else(PoisonError::into_inner).cache_file = Some(path.to_path_buf());+}+ pub fn add_search_dir(dir: &Path) -> usize { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); if !r.extra_dirs.iter().any(|d| d == dir) {@@ -105,6 +236,14 @@ pub fn add_search_dir(dir: &Path) -> usize { } let found = scan_paths(&[dir.to_path_buf()]); let n = found.len();+ // A scan under way lists the folder too (its bundles are loaded now, so that is cheap):+ // a list of only this folder would end it with the standard folders' plugins missing.+ if r.scanned.is_none()+ && let Some(job) = r.job.as_mut()+ {+ job.include(paths::find_bundles(&[dir.to_path_buf()]));+ return n;+ } let list = r.scanned.get_or_insert_with(Vec::new); for d in found { if !list.iter().any(|o| o.id == d.id) {@@ -269,4 +408,39 @@ mod tests { assert_eq!(unit_from_label("st"), Unit::Semitones); assert_eq!(unit_from_label("furlongs"), Unit::None); }++ fn folder_with(name: &str, bundles: &[&str]) -> PathBuf {+ let d = std::env::temp_dir().join(format!("sc-vst3-job-{name}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ std::fs::create_dir_all(&d).unwrap();+ for b in bundles {+ // Not a loadable module: the scan records why and moves on.+ std::fs::write(d.join(b), b"not a plugin").unwrap();+ }+ d+ }++ #[test]+ fn a_folder_added_during_a_scan_joins_it_and_unchanged_bundles_come_from_the_cache() {+ let (a, b) = (folder_with("a", &["One.vst3"]), folder_with("b", &["Two.vst3", "Three.vst3"]));+ let mut job = ScanJob::new(std::slice::from_ref(&a), HashMap::new());+ assert_eq!(job.total, 1);+ job.include(paths::find_bundles(std::slice::from_ref(&b)));+ job.include(paths::find_bundles(std::slice::from_ref(&b)));+ assert_eq!(job.total, 3, "an added folder's bundles are queued once");+ // At most one bundle is loaded per step: three steps for three uncached bundles.+ for _ in 0..3 {+ job.step(Duration::MAX);+ }+ assert!(job.pending.is_empty());+ assert_eq!(job.entries.len(), 3);+ assert!(job.entries.iter().all(|e| e.error.is_some() && e.descriptors.is_empty()));+ // A later scan lists every unchanged bundle from the cache in one step, loading none.+ let known: HashMap<PathBuf, Entry> = job.entries.iter().map(|e| (e.path.clone(), e.clone())).collect();+ let mut again = ScanJob::new(&[a.clone(), b.clone()], known);+ again.step(Duration::MAX);+ assert!(again.pending.is_empty(), "nothing had to be loaded");+ assert_eq!(again.entries, job.entries, "the same entries, in the same (path) order");+ let _ = (std::fs::remove_dir_all(&a), std::fs::remove_dir_all(&b));+ } }