From 6758539019d60c5cfd3a7239b5ee801cf1959091 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 7 Mar 2026 19:57:09 +0800 Subject: [PATCH 1/2] Add a proof-of-concept for a long-running function While it's using `gix::Progress`, it could also use anything else for that. There might also be alternatives that offer checks for cancellation as part of the progress, but I found it better to make this explcit. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- crates/but-api/src/lib.rs | 224 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/crates/but-api/src/lib.rs b/crates/but-api/src/lib.rs index e2f6b5d4ada..1bca1dfbe59 100644 --- a/crates/but-api/src/lib.rs +++ b/crates/but-api/src/lib.rs @@ -38,3 +38,227 @@ pub mod platform; pub mod schema; pub mod panic_capture; + +/// A module for proof-of-concepts +pub mod poc { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, + }; + + /// Either the actual data that is more and more complete, or increments that can be merged + /// into the actual data by the receiver. + /// Sending all data whenever it changes is probably better. + pub struct Data(pub usize); + + /// Set `duration` to decide how long the function should run, without blocking the caller. + /// IRL the duration would be determined by the amount of work to be done. + /// + /// Use `progress` to see fine-grained progress information, either flat or as [`gix::NestedProgress`] + /// so that a progress tree can be built for complex progress ['visualisations'](https://asciinema.org/a/315956). + /// Please note that the underlying implementation, [`Prodash`](https://github.com/GitoxideLabs/prodash?tab=readme-ov-file), + /// also provides renderers. However, there are other crates as well and there is no reason these shouldn't be used if + /// these seem fitter. + /// + /// Use `should_interrupt` which should become `true` if the function should stop processing. + /// It must live outside of `scope`, like any other value borrowed by a scoped thread. + /// + /// Return a receiver for the data (incremental or increasingly complete, depending on what's more suitable). + /// + /// # Cancellation + /// + /// The task can be stopped in two ways: + /// + /// - by dropping the receiver + /// - this is easy, but has the disadvantage that the sender only stops once it tries to send the next result and fails + /// doing so. + /// - by setting `should_interrupt` to `true` + /// - this mechanism is fine-grained, and the callee is expected to pull the value often, so it will respond + /// swiftly to cancellation requests. + /// + /// # `[but_api]` Integration + /// + /// This function can't be `[but_api]` annotated until it learns how to deal with `duration` and more importantly, + /// `progress`, the return channel and how to wire up `should_interrupt`. If we want, any of these could serve as markers + /// to indicate long-runnning functions + /// + /// # Why not `async`? + /// + /// Our computations are not IO bound but compute bound, so there is no benefit to `async` or `tokio`. + /// And I really, really want to avoid all the issues we will be getting when `async` is used, `but-ctx` and `gix::Repository` + /// do not like `await` and require workarounds. + /// + /// At first, the integration should be implemented by hand (i.e. for NAPI) before it's generalised. + pub fn long_running_non_blocking_scoped_thread<'scope, 'env>( + scope: &'scope thread::Scope<'scope, 'env>, + duration: Duration, + progres: impl gix::Progress + 'env, + should_interrupt: &'env AtomicBool, + ) -> std::sync::mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + scope.spawn(move || run_long_running_worker(duration, progres, should_interrupt, tx)); + rx + } + + /// Like [`long_running_non_blocking_scoped_thread()`], but uses a regular thread and an owned + /// cancellation flag so the task can outlive the current stack frame. + pub fn long_running_non_blocking_thread( + duration: Duration, + progres: impl gix::Progress + 'static, + should_interrupt: Arc, + ) -> std::sync::mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || run_long_running_worker(duration, progres, &should_interrupt, tx)); + rx + } + + fn run_long_running_worker( + duration: Duration, + mut progres: impl gix::Progress, + should_interrupt: &AtomicBool, + tx: mpsc::Sender>, + ) { + const UPDATE_INTERVAL: Duration = Duration::from_millis(100); + const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(20); + + let total_steps = usize::max( + 1, + duration + .as_millis() + .div_ceil(UPDATE_INTERVAL.as_millis()) + .try_into() + .unwrap_or(usize::MAX), + ); + let start = Instant::now(); + + progres.init(Some(total_steps), gix::progress::steps()); + progres.set_name("proof of concept task".into()); + + for step in 1..=total_steps { + let scheduled_at = start + duration.mul_f64(step as f64 / total_steps as f64); + while let Some(remaining) = scheduled_at.checked_duration_since(Instant::now()) { + if should_interrupt.load(Ordering::Relaxed) { + progres.fail(format!("interrupted at step {}/{}", step - 1, total_steps)); + return; + } + thread::sleep(remaining.min(INTERRUPT_POLL_INTERVAL)); + } + + progres.set(step); + if tx.send(Ok(Data(step))).is_err() { + progres.info("receiver dropped".into()); + return; + } + } + + progres.done("completed".into()); + progres.show_throughput(start); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, + }; + + use super::poc; + + #[test] + fn long_running_non_blocking_scoped_thread_returns_before_work_completes() { + let should_interrupt = AtomicBool::new(false); + + thread::scope(|scope| { + let start = Instant::now(); + let rx = poc::long_running_non_blocking_scoped_thread( + scope, + Duration::from_millis(50), + gix::progress::Discard, + &should_interrupt, + ); + + assert!(start.elapsed() < Duration::from_millis(25)); + assert!(matches!( + rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let values = rx + .into_iter() + .collect::>>() + .expect("proof-of-concept task should complete"); + + assert_eq!(values.last().map(|data| data.0), Some(1)); + }); + } + + #[test] + fn long_running_non_blocking_scoped_thread_stops_when_interrupted() { + let should_interrupt = AtomicBool::new(false); + + thread::scope(|scope| { + let rx = poc::long_running_non_blocking_scoped_thread( + scope, + Duration::from_millis(200), + gix::progress::Discard, + &should_interrupt, + ); + should_interrupt.store(true, Ordering::Relaxed); + + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) + )); + }); + } + + #[test] + fn long_running_non_blocking_thread_returns_before_work_completes() { + let should_interrupt = Arc::new(AtomicBool::new(false)); + let start = Instant::now(); + let rx = poc::long_running_non_blocking_thread( + Duration::from_millis(50), + gix::progress::Discard, + should_interrupt, + ); + + assert!(start.elapsed() < Duration::from_millis(25)); + assert!(matches!( + rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let values = rx + .into_iter() + .collect::>>() + .expect("proof-of-concept task should complete"); + + assert_eq!(values.last().map(|data| data.0), Some(1)); + } + + #[test] + fn long_running_non_blocking_thread_stops_when_interrupted() { + let should_interrupt = Arc::new(AtomicBool::new(false)); + let rx = poc::long_running_non_blocking_thread( + Duration::from_millis(200), + gix::progress::Discard, + should_interrupt.clone(), + ); + should_interrupt.store(true, Ordering::Relaxed); + + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) + )); + } +} From b350f2c607f4c75f3996aca3722426a7a94c4d75 Mon Sep 17 00:00:00 2001 From: "chatgpt-codex-connector[bot]" <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:18:25 +0800 Subject: [PATCH 2/2] Show the long-running function call via `but-testing`. That's particularly interesting for the Progress visualisation and instantiation, as our 'renderer' would be a forwarder of sorts to pass the progress state on to the frontend. Co-authored-by: Sebastian Thiel --- Cargo.lock | 19 ++++++++ crates/but-testing/Cargo.toml | 5 ++- crates/but-testing/src/args.rs | 8 +++- crates/but-testing/src/command/mod.rs | 1 + crates/but-testing/src/command/poc.rs | 63 +++++++++++++++++++++++++++ crates/but-testing/src/main.rs | 3 ++ 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 crates/but-testing/src/command/poc.rs diff --git a/Cargo.lock b/Cargo.lock index 7252c7d8918..3ab276721c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1526,6 +1526,7 @@ name = "but-testing" version = "0.0.0" dependencies = [ "anyhow", + "but-api", "but-core", "but-ctx", "but-db", @@ -1546,7 +1547,9 @@ dependencies = [ "gitbutler-reference", "gitbutler-stack", "gix", + "humantime", "itertools", + "prodash", "serde_json", "tokio", "tracing", @@ -2428,6 +2431,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crosstermion" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ae462f0b868614980d59df41e217a77648de7ad7cf8b2a407155659896d889" +dependencies = [ + "crossterm", + "nu-ansi-term", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -4395,7 +4408,9 @@ dependencies = [ "gix-worktree-state", "gix-worktree-stream", "nonempty", + "parking_lot", "serde", + "signal-hook 0.4.3", "smallvec", "thiserror 2.0.18", ] @@ -8145,7 +8160,11 @@ version = "31.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ + "crosstermion", + "is-terminal", + "jiff", "parking_lot", + "unicode-width", ] [[package]] diff --git a/crates/but-testing/Cargo.toml b/crates/but-testing/Cargo.toml index fb07bb3bc30..fe72aee2515 100644 --- a/crates/but-testing/Cargo.toml +++ b/crates/but-testing/Cargo.toml @@ -42,15 +42,18 @@ gitbutler-reference.workspace = true gitbutler-commit = { workspace = true, optional = true, features = ["testing"] } clap = { workspace = true, features = ["env"] } -gix.workspace = true +gix = { workspace = true, features = ["interrupt"] } anyhow.workspace = true itertools.workspace = true +humantime = "2.3.0" tracing-forest.workspace = true tracing-subscriber.workspace = true tracing.workspace = true dirs-next = "2.0.0" serde_json.workspace = true tokio.workspace = true +but-api = { path = "../but-api" } +prodash = { version = "31.0.0", default-features = false, features = ["progress-tree", "render-line", "render-line-autoconfigure", "render-line-crossterm"] } [lints] workspace = true diff --git a/crates/but-testing/src/args.rs b/crates/but-testing/src/args.rs index 4398ae2b72a..e17a6340646 100644 --- a/crates/but-testing/src/args.rs +++ b/crates/but-testing/src/args.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use gitbutler_reference::RemoteRefname; use gitbutler_stack::StackId; @@ -269,6 +269,12 @@ pub enum Subcommands { /// the short-name of the reference to delete. short_name: String, }, + /// Run the proof-of-concept long-running operation with progress rendering and interrupt handling. + LongRunning { + /// How long the proof-of-concept task should run, like `5s`, `250ms`, or `1m`. + #[arg(short = 'd', long, default_value = "5s", value_parser = humantime::parse_duration)] + duration: Duration, + }, } #[cfg(test)] diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index 5f0ab33e3de..6e8bd8d19cc 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -43,6 +43,7 @@ use gitbutler_branch_actions::BranchListingFilter; use crate::command::discard_change::IndicesOrHeaders; pub mod diff; +pub mod poc; pub mod project; pub mod assignment { diff --git a/crates/but-testing/src/command/poc.rs b/crates/but-testing/src/command/poc.rs new file mode 100644 index 00000000000..a912a049f04 --- /dev/null +++ b/crates/but-testing/src/command/poc.rs @@ -0,0 +1,63 @@ +use std::{io, sync::Arc, thread, time::Duration}; + +use anyhow::{Result, bail}; + +const DEFAULT_FRAME_RATE: f32 = 60.0; + +pub fn long_running(duration: Duration, trace: bool) -> Result<()> { + #[allow(unsafe_code)] + let _interrupt_handler = unsafe { + // SAFETY: The closure only calls a function that in turn sets an atomic. No memory handling is triggered. + gix::interrupt::init_handler(1, gix::interrupt::trigger)?.auto_deregister() + }; + + let progress = progress_tree(trace); + let renderer = setup_line_renderer(&progress); + let result = thread::scope(|scope| -> Result> { + let rx = but_api::poc::long_running_non_blocking_scoped_thread( + scope, + duration, + progress.add_child("long-running"), + &gix::interrupt::IS_INTERRUPTED, + ); + let mut last = None; + for data in rx { + last = Some(data?.0); + } + Ok(last) + }); + renderer.shutdown_and_wait(); + + let last = result?; + if gix::interrupt::is_triggered() { + bail!("Interrupted"); + } + if let Some(last) = last { + println!("Completed {last} step(s)."); + } + Ok(()) +} + +fn progress_tree(trace: bool) -> Arc { + prodash::tree::root::Options { + message_buffer_capacity: if trace { 10_000 } else { 200 }, + ..Default::default() + } + .into() +} + +fn setup_line_renderer(progress: &Arc) -> prodash::render::line::JoinHandle { + prodash::render::line( + io::stderr(), + Arc::downgrade(progress), + prodash::render::line::Options { + frames_per_second: DEFAULT_FRAME_RATE, + initial_delay: Some(Duration::from_millis(500)), + timestamp: true, + throughput: true, + hide_cursor: true, + ..prodash::render::line::Options::default() + } + .auto_configure(prodash::render::line::StreamKind::Stderr), + ) +} diff --git a/crates/but-testing/src/main.rs b/crates/but-testing/src/main.rs index 1a3ff7535a0..323e946e8b8 100644 --- a/crates/but-testing/src/main.rs +++ b/crates/but-testing/src/main.rs @@ -43,6 +43,9 @@ async fn main() -> Result<()> { switch_to_workspace.to_owned(), ), args::Subcommands::RemoveProject { project_name } => command::project::remove(project_name), + args::Subcommands::LongRunning { duration } => { + command::poc::long_running(*duration, args.trace > 0) + } args::Subcommands::RemoveReference { permit_empty_stacks, keep_metadata,