Skip to content

test: support -N, -G and -O on Windows - #13658

Open
sylvestre wants to merge 1 commit into
uutils:mainfrom
sylvestre:test-windows-file-conditions
Open

test: support -N, -G and -O on Windows#13658
sylvestre wants to merge 1 commit into
uutils:mainfrom
sylvestre:test-windows-file-conditions

Conversation

@sylvestre

Copy link
Copy Markdown
Contributor

No description provided.

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 345 untouched benchmarks
⏩ 46 skipped benchmarks1


Comparing sylvestre:test-windows-file-conditions (79d083b) with main (a0ad917)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/cut/bounded-memory (fails in this run but passes in the 'main' branch)

@sylvestre

Copy link
Copy Markdown
Contributor Author

@codspeedbot explain why this is faster (and this test unstable)

@sylvestre
sylvestre force-pushed the test-windows-file-conditions branch from 002dca7 to b9f7518 Compare July 30, 2026 17:03
@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

@codspeedbot explain why this is faster (and this test unstable)

Short answer: it isn't actually faster — the base run is the outlier, and this du benchmark is intrinsically noisy.

Why the report shows +3.28%

This PR touches only src/uu/test (+ tests and Cargo.lock); nothing in the du code path changes. The instrument breakdown confirms there is no code-level change:

Metric (Simulation) BASE d169a9a HEAD 002dca7 Δ
Instructions 18.27 ms 18.15 ms −0.7%
Cache misses 39.58 ms 38.49 ms −2.7%
Memory access 5.15 ms 4.36 ms −15%
Syscalls executed 81,966 76,210 −7%
CPU total 63.0 ms 61.0 ms −3.3%

The instruction count — the part that actually reflects the code — is flat. The whole delta sits in the modeled cache/memory cost and in the number of syscalls the run performed.

Looking at the last few main runs for du_max_depth_balanced_tree[(6, 4, 10)]:

Commit on main CPU total
0cb3473 60.9 ms
cee8424 61.2 ms
5f15efb 61.5 ms
93025b5 61.5 ms
d169a9a (BASE of this PR) 63.0 ms
this PR 61.0 ms

So the head value is right on the usual baseline; the base commit happened to land 3% high. Note also the environment warning in the report: the du benchmarks' base ran on an AMD EPYC 9V74 and the head on an AMD EPYC 7763. In Simulation mode the cache model is derived from the physical CPU, so the cache-miss term — which is the dominant one here — shifts with the runner.

Why this benchmark is unstable

The flamegraph shows where the time goes: uu_du::safe_du recursion over ~5.5k directories / ~55k files, and of the 61 ms modeled total only ~18 ms is instructions — ~38 ms is cache misses and ~4 ms memory access, on top of ~76k syscalls (openat/fstatat/readdir/close) and heavy malloc/free traffic.

That mix is the worst case for reproducibility:

  1. Filesystem-bound, not CPU-bound. The work is dominated by directory traversal in a fresh TempDir. Syscall count alone moved by 7% between two runs of identical code — readdir buffer refills and per-entry work depend on how the tree happens to be laid out on disk that day.
  2. Cache/memory model dominates (~70% of the score). Allocation addresses, inode values feeding the FxHashSet dedup, and path string layout differ every run, and that directly changes the simulated cache behaviour. Instructions are stable; the cache term is not.
  3. Runner-dependent. As above, the simulated cache is derived from the host CPU, so base/head landing on different machines adds another few percent.

This is a known trait of this family: du_bench.rs already has three benchmarks (du_balanced_tree, du_all_balanced_tree, du_human_balanced_tree) commented out with /* too much variance */. du_max_depth_balanced_tree has the same problem, just below the threshold that got the others disabled.

If you want to fix it

Happy to open a follow-up PR (this one isn't the right place) doing one of:

  • shrink the tree and raise the iteration count so per-run filesystem layout weighs less, and/or build the tree once via with_inputs consistently across the du benchmarks;
  • or simply retire du_max_depth_balanced_tree like its three siblings, keeping the du coverage on the wide/deep-tree variants which have been steadier.

Just say which you prefer and I'll take care of it.

Summary
  • Warming up the performance wizard...
  • Investigating the du benchmark change — Identified the reported +3.28% as base-run noise: instruction count is unchanged, the delta lives entirely in the cache/memory model and syscall count
  • Handling comment — Comment handling finished successfully

Learnings updated. See what CodSpeed remembers about this repository.

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@sylvestre

Copy link
Copy Markdown
Contributor Author

@codspeedbot yes, fix it in a new pr

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

@codspeedbot yes, fix it in a new pr

I dug into it, and the instability is not the filesystem or the cache model — it comes from du itself. Unfortunately I don't have push access to open the follow-up PR from here, so the full patch is at the bottom of this comment.

Root cause

du runs the traversal on the main thread and pushes one message per directory (and one per file with -a) to the printing thread through an mpsc channel. Every send that finds the printing thread parked costs a futex wake-up, and whether it is parked depends purely on how the two threads happen to be scheduled.

Counting syscalls by type over two runs of the same binary on the same tree (5,460 dirs, 13,650 files) — everything is deterministic except the futexes:

Syscall Run 1 Run 2
newfstatat 24,586 24,586
getdents64 21,844 21,844
openat 10,954 10,954
close 10,927 10,927
fcntl 10,925 10,925
lseek / dup 5,461 / 5,461 5,461 / 5,461
futex 5,366 6,154

At most one extra syscall per directory — which is exactly what CodSpeed recorded between two runs of main: du_max_depth_balanced_tree 76,391 → 81,966 syscalls (+5,575 for 5,460 directories), du_summarize_balanced_tree 19,169 → 20,514 (+1,345 for 1,364 directories). The wide-tree benchmarks, whose tight traversal loop keeps the printing thread awake, stayed within ±2 syscalls. So it was never the benchmark harness: the deep, recursive traversals are simply burstier, the printer parks more often, and the syscall count follows the scheduler.

Fix

1. du: batch the messages sent to the printing thread. They are accumulated in a 16-message buffer and shipped as one Vec, so there is at most one wake-up per batch instead of one per directory. Output order is untouched (single buffer, flushed in order, drained in order) and the remainder is flushed before the channel is dropped. Futex calls drop from 5,366–6,154 to 1,198–1,346, and the residual run-to-run swing is bounded by one wake-up per 16 directories.

2. Benchmarks: stop measuring the teardown of the temporary tree. The with_inputs(...).bench_values(...) benchmarks moved the TempDir into the timed closure, so its recursive removal was measured as part of du — for du_max_depth_balanced_tree that is ~57k unlink/rmdir syscalls on top of the ~74k the traversal itself performs. All five benchmarks now share one helper based on bench_refs.

Results (simulation, 5 consecutive local runs of the whole du suite)

Benchmark Before After Change
du_summarize_balanced_tree[(5, 4, 10)] 6.17 ms 5.77 ms −6.5%
du_wide_tree[(5000, 500)] 9.15 ms 7.82 ms −14.5%
du_all_wide_tree[(5000, 500)] 16.69 ms 13.38 ms −19.8%
du_deep_tree[(100, 3)] 1.06 ms 0.89 ms −16.0%
du_max_depth_balanced_tree[(6, 4, 10)] 23.92 ms 22.35 ms −6.6%

Part of the gain on the wide/deep benchmarks is the teardown no longer being measured; the rest, and all of the gain on the balanced ones, is the reduced futex traffic.

Validation

  • Output compared byte-for-byte with the pre-change binary for -a, -s, -c, -h, -b, -x, -L, --inodes, --time, --separate-dirs, --threshold, --max-depth, and error paths — identical; --max-depth and -a output also matches GNU du.
  • cargo test -p coreutils --test tests -- test_du: 112 passed. test_du_inaccessible_directory fails with and without the change in my sandbox because the tests run as root.
  • cargo fmt and cargo clippy -p uu_du --all-targets clean.

The three benchmarks commented out with /* too much variance */ are rewritten on top of the new helper but left disabled — worth re-enabling once a few main runs confirm the stability of the active ones.

Patch (git apply)
diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs
index 0b63ce9..25437e0 100644
--- a/src/uu/du/benches/du_bench.rs
+++ b/src/uu/du/benches/du_bench.rs
@@ -3,20 +3,32 @@
 // For the full copyright and license information, please view the LICENSE
 // file that was distributed with this source code.
 
+use std::path::Path;
+
 use divan::{Bencher, black_box};
 use tempfile::TempDir;
 use uu_du::uumain;
 use uucore::benchmark::{fs_tree, run_util_function};
 
-/// Helper to run du with given arguments on a directory
-fn bench_du_with_args(bencher: Bencher, temp_dir: &TempDir, args: &[&str]) {
-    let temp_path_str = temp_dir.path().to_str().unwrap();
-    let mut full_args = args.to_vec();
-    full_args.push(temp_path_str);
-
-    bencher.bench(|| {
-        black_box(run_util_function(uumain, &full_args));
-    });
+/// Helper to run du with given arguments on a freshly created directory tree
+///
+/// The tree is built in the input phase (`with_inputs`) and handed to the timed
+/// closure by reference (`bench_refs`), so that both the creation *and* the
+/// deletion of the temporary tree stay out of the measured region. Passing the
+/// tree by value would make each iteration pay for the recursive removal of tens
+/// of thousands of entries, which has nothing to do with what du does.
+fn bench_du(bencher: Bencher, build_tree: impl Fn(&Path) + Sync, args: &[&str]) {
+    bencher
+        .with_inputs(|| {
+            let temp_dir = TempDir::new().unwrap();
+            build_tree(temp_dir.path());
+            temp_dir
+        })
+        .bench_refs(|temp_dir| {
+            let mut full_args = args.to_vec();
+            full_args.push(temp_dir.path().to_str().unwrap());
+            black_box(run_util_function(uumain, &full_args));
+        });
 }
 
 /* too much variance
@@ -26,9 +38,11 @@ fn du_balanced_tree(
     bencher: Bencher,
     (depth, dirs_per_level, files_per_dir): (usize, usize, usize),
 ) {
-    let temp_dir = TempDir::new().unwrap();
-    fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir);
-    bench_du_with_args(bencher, &temp_dir, &[]);
+    bench_du(
+        bencher,
+        |path| fs_tree::create_balanced_tree(path, depth, dirs_per_level, files_per_dir),
+        &[],
+    );
 }
 */
 
@@ -39,9 +53,11 @@ fn du_all_balanced_tree(
     bencher: Bencher,
     (depth, dirs_per_level, files_per_dir): (usize, usize, usize),
 ) {
-    let temp_dir = TempDir::new().unwrap();
-    fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir);
-    bench_du_with_args(bencher, &temp_dir, &["-a"]);
+    bench_du(
+        bencher,
+        |path| fs_tree::create_balanced_tree(path, depth, dirs_per_level, files_per_dir),
+        &["-a"],
+    );
 }
 */
 
@@ -52,58 +68,42 @@ fn du_human_balanced_tree(
     bencher: Bencher,
     (depth, dirs_per_level, files_per_dir): (usize, usize, usize),
 ) {
-    let temp_dir = TempDir::new().unwrap();
-    fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir);
-    bench_du_with_args(bencher, &temp_dir, &["-h"]);
+    bench_du(
+        bencher,
+        |path| fs_tree::create_balanced_tree(path, depth, dirs_per_level, files_per_dir),
+        &["-h"],
+    );
 }
 */
 
 /// Benchmark du on wide directory structures (many files/dirs, shallow)
 #[divan::bench(args = [(5000, 500)])]
 fn du_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) {
-    bencher
-        .with_inputs(|| {
-            let temp_dir = TempDir::new().unwrap();
-            fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs);
-            temp_dir
-        })
-        .bench_values(|temp_dir| {
-            let temp_path_str = temp_dir.path().to_str().unwrap();
-            let args = vec![temp_path_str];
-            black_box(run_util_function(uumain, &args));
-        });
+    bench_du(
+        bencher,
+        |path| fs_tree::create_wide_tree(path, total_files, total_dirs),
+        &[],
+    );
 }
 
 /// Benchmark du -a on wide directory structures
 #[divan::bench(args = [(5000, 500)])]
 fn du_all_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) {
-    bencher
-        .with_inputs(|| {
-            let temp_dir = TempDir::new().unwrap();
-            fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs);
-            temp_dir
-        })
-        .bench_values(|temp_dir| {
-            let temp_path_str = temp_dir.path().to_str().unwrap();
-            let args = vec![temp_path_str, "-a"];
-            black_box(run_util_function(uumain, &args));
-        });
+    bench_du(
+        bencher,
+        |path| fs_tree::create_wide_tree(path, total_files, total_dirs),
+        &["-a"],
+    );
 }
 
 /// Benchmark du on deep directory structures
 #[divan::bench(args = [(100, 3)])]
 fn du_deep_tree(bencher: Bencher, (depth, files_per_level): (usize, usize)) {
-    bencher
-        .with_inputs(|| {
-            let temp_dir = TempDir::new().unwrap();
-            fs_tree::create_deep_tree(temp_dir.path(), depth, files_per_level);
-            temp_dir
-        })
-        .bench_values(|temp_dir| {
-            let temp_path_str = temp_dir.path().to_str().unwrap();
-            let args = vec![temp_path_str];
-            black_box(run_util_function(uumain, &args));
-        });
+    bench_du(
+        bencher,
+        |path| fs_tree::create_deep_tree(path, depth, files_per_level),
+        &[],
+    );
 }
 
 /// Benchmark du -s (summarize) on balanced tree
@@ -112,9 +112,11 @@ fn du_summarize_balanced_tree(
     bencher: Bencher,
     (depth, dirs_per_level, files_per_dir): (usize, usize, usize),
 ) {
-    let temp_dir = TempDir::new().unwrap();
-    fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir);
-    bench_du_with_args(bencher, &temp_dir, &["-s"]);
+    bench_du(
+        bencher,
+        |path| fs_tree::create_balanced_tree(path, depth, dirs_per_level, files_per_dir),
+        &["-s"],
+    );
 }
 
 /// Benchmark du with --max-depth
@@ -123,9 +125,11 @@ fn du_max_depth_balanced_tree(
     bencher: Bencher,
     (depth, dirs_per_level, files_per_dir): (usize, usize, usize),
 ) {
-    let temp_dir = TempDir::new().unwrap();
-    fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir);
-    bench_du_with_args(bencher, &temp_dir, &["--max-depth=2"]);
+    bench_du(
+        bencher,
+        |path| fs_tree::create_balanced_tree(path, depth, dirs_per_level, files_per_dir),
+        &["--max-depth=2"],
+    );
 }
 
 fn main() {
diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs
index f30127e..2c25510 100644
--- a/src/uu/du/src/du.rs
+++ b/src/uu/du/src/du.rs
@@ -3,11 +3,12 @@
 // For the full copyright and license information, please view the LICENSE
 // file that was distributed with this source code.
 //
-// spell-checker:ignore fstatat openat dirfd
+// spell-checker:ignore fstatat openat dirfd futex
 
 use clap::{Arg, ArgAction, ArgMatches, Command, builder::PossibleValue};
 use glob::{Pattern, PatternError};
 use rustc_hash::FxHashSet as HashSet;
+use std::cell::RefCell;
 use std::env;
 use std::ffi::{OsStr, OsString};
 use std::fs::{self, DirEntry, File, Metadata};
@@ -335,10 +336,10 @@ fn safe_du(
     options: &TraversalOptions,
     depth: usize,
     seen_inodes: &mut HashSet<FileInfo>,
-    print_tx: &mpsc::Sender<UResult<StatPrintInfo>>,
+    print_tx: &StatPrinterTx,
     parent_fd: Option<&DirFd>,
     initial_stat: Option<std::io::Result<Stat>>,
-) -> Result<Stat, Box<mpsc::SendError<UResult<StatPrintInfo>>>> {
+) -> Result<Stat, Box<SendError>> {
     // The caller provides an already-computed stat for this entry, which lets us
     // avoid re-stating it here. For subdirectories this saves both a redundant
     // `fstatat` and an expensive full-path `lstat` per directory (the dominant cost
@@ -368,9 +369,7 @@ fn safe_du(
                         let error = e.map_err_context(
                             || translate!("du-error-cannot-access", "path" => path.quote()),
                         );
-                        if let Err(send_error) = print_tx.send(Err(error)) {
-                            return Err(Box::new(send_error));
-                        }
+                        print_tx.send(Err(error))?;
                         return Err(Box::new(mpsc::SendError(Err(USimpleError::new(
                             0,
                             "Error already handled",
@@ -381,9 +380,7 @@ fn safe_du(
                     let error = e.map_err_context(
                         || translate!("du-error-cannot-access", "path" => path.quote()),
                     );
-                    if let Err(send_error) = print_tx.send(Err(error)) {
-                        return Err(Box::new(send_error));
-                    }
+                    print_tx.send(Err(error))?;
                     return Err(Box::new(mpsc::SendError(Err(USimpleError::new(
                         0,
                         "Error already handled",
@@ -592,10 +589,10 @@ fn du_regular(
     options: &TraversalOptions,
     depth: usize,
     seen_inodes: &mut HashSet<FileInfo>,
-    print_tx: &mpsc::Sender<UResult<StatPrintInfo>>,
+    print_tx: &StatPrinterTx,
     ancestors: Option<&mut HashSet<FileInfo>>,
     symlink_depth: Option<usize>,
-) -> Result<Stat, Box<mpsc::SendError<UResult<StatPrintInfo>>>> {
+) -> Result<Stat, Box<SendError>> {
     // Maximum symlink depth to prevent infinite loops
     const MAX_SYMLINK_DEPTH: usize = 40;
 
@@ -827,6 +824,76 @@ struct StatPrintInfo {
     depth: usize,
 }
 
+/// Error returned when a message cannot be handed over to the printing thread.
+type SendError = mpsc::SendError<UResult<StatPrintInfo>>;
+
+/// Number of messages accumulated before they are handed over to the printing thread.
+const PRINT_BATCH_SIZE: usize = 16;
+
+/// Buffered sender towards the printing thread.
+///
+/// The traversal produces one message per directory (and one per file with `-a`).
+/// Sending them one at a time wakes the printing thread up for nearly every
+/// message, which costs a futex syscall per directory and makes the traversal
+/// sensitive to how the two threads happen to be scheduled. Messages are
+/// therefore grouped in small batches before being sent, which keeps the output
+/// order untouched while removing most of the cross-thread wake-ups.
+struct StatPrinterTx {
+    tx: mpsc::Sender<Vec<UResult<StatPrintInfo>>>,
+    buffer: RefCell<Vec<UResult<StatPrintInfo>>>,
+}
+
+impl StatPrinterTx {
+    fn new(tx: mpsc::Sender<Vec<UResult<StatPrintInfo>>>) -> Self {
+        Self {
+            tx,
+            buffer: RefCell::new(Vec::with_capacity(PRINT_BATCH_SIZE)),
+        }
+    }
+
+    fn send(&self, message: UResult<StatPrintInfo>) -> Result<(), Box<SendError>> {
+        let mut buffer = self.buffer.borrow_mut();
+        buffer.push(message);
+        if buffer.len() < PRINT_BATCH_SIZE {
+            return Ok(());
+        }
+        let batch = std::mem::replace(&mut *buffer, Vec::with_capacity(PRINT_BATCH_SIZE));
+        Self::send_batch(&self.tx, batch)
+    }
+
+    /// Hand over the messages buffered so far, if any.
+    fn flush(&self) -> Result<(), Box<SendError>> {
+        let batch = std::mem::take(&mut *self.buffer.borrow_mut());
+        if batch.is_empty() {
+            return Ok(());
+        }
+        Self::send_batch(&self.tx, batch)
+    }
+
+    fn send_batch(
+        tx: &mpsc::Sender<Vec<UResult<StatPrintInfo>>>,
+        batch: Vec<UResult<StatPrintInfo>>,
+    ) -> Result<(), Box<SendError>> {
+        // The printing thread is gone: report the failure with the first message
+        // that could not be delivered, like an unbuffered send would.
+        tx.send(batch).map_err(|mpsc::SendError(batch)| {
+            let message = batch
+                .into_iter()
+                .next()
+                .unwrap_or_else(|| Err(USimpleError::new(1, String::new())));
+            Box::new(mpsc::SendError(message))
+        })
+    }
+}
+
+impl Drop for StatPrinterTx {
+    fn drop(&mut self) {
+        // Deliver whatever is left; a failure here means the printing thread is
+        // already gone, which is reported when it is joined.
+        let _ = self.flush();
+    }
+}
+
 impl StatPrinter {
     fn choose_size(&self, stat: &Stat) -> u64 {
         if self.inodes {
@@ -840,13 +907,11 @@ impl StatPrinter {
         }
     }
 
-    fn print_stats(&self, rx: &mpsc::Receiver<UResult<StatPrintInfo>>) -> UResult<()> {
+    fn print_stats(&self, rx: &mpsc::Receiver<Vec<UResult<StatPrintInfo>>>) -> UResult<()> {
         let mut grand_total = 0;
-        loop {
-            let received = rx.recv();
-
-            match received {
-                Ok(message) => match message {
+        for batch in rx {
+            for message in batch {
+                match message {
                     Ok(stat_info) => {
                         let size = self.choose_size(&stat_info.stat);
 
@@ -866,8 +931,7 @@ impl StatPrinter {
                         }
                     }
                     Err(e) => show!(e),
-                },
-                Err(_) => break,
+                }
             }
         }
 
@@ -1141,7 +1205,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
     }
 
     // Use separate thread to print output, so we can print finished results while computation is still running
-    let (print_tx, rx) = mpsc::channel::<UResult<StatPrintInfo>>();
+    let (tx, rx) = mpsc::channel::<Vec<UResult<StatPrintInfo>>>();
+    let print_tx = StatPrinterTx::new(tx);
     let printing_thread = thread::spawn(move || stat_printer.print_stats(&rx));
 
     // Check existence of path provided in argument
@@ -1243,6 +1308,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
         }
     }
 
+    print_tx
+        .flush()
+        .map_err(|e| USimpleError::new(1, e.to_string()))?;
     drop(print_tx);
 
     printing_thread
Summary
  • Warming up the performance wizard...
  • Analyzing the du benchmarks — Reviewed du_bench.rs and the fs_tree helpers
  • Measuring baseline stability — Found that only the balanced-tree du benchmarks vary run-to-run; the wide/deep-tree ones are reproducible to ~0.1%
  • Isolating the source of the variance — The noisy benchmarks reuse one shared tree across iterations; the stable ones build a fresh input per iteration
  • Validating the reworked benchmarks — Traced the instability to futex wake-ups on du's print channel: it is the only syscall whose count changes between identical runs (5366 vs 6154)
  • Fixing the root cause in du — Batched the print-channel messages, validated output against the previous binary and the du test suite
  • Opening the follow-up pull request — No push access to uutils/coreutils from this environment, so the change is delivered as a patch in the PR comment instead
  • Handling comment — Comment handling finished successfully

Learnings updated. See what CodSpeed remembers about this repository.

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@sylvestre
sylvestre force-pushed the test-windows-file-conditions branch from b9f7518 to 79d083b Compare July 31, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant