From 214da5c83efa9118d2e64ca1a8b6c0768d895462 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Mon, 12 Jan 2026 21:47:31 -0800 Subject: [PATCH 1/5] experimenting ctrlc stuff --- packages/copper/Cargo.toml | 5 +- packages/copper/src/atomic.rs | 7 + packages/copper/src/cli/ctrlc.rs | 200 ++++++++++++++++++++ packages/copper/src/cli/mod.rs | 2 + packages/copper/src/cli/progress/builder.rs | 6 +- 5 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 packages/copper/src/cli/ctrlc.rs diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index bcd72bf..9e90689 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -24,6 +24,7 @@ terminal_size = { version = "0.4.3", optional = true } unicode-width = { version = "0.2.2", features = ["cjk"], optional = true } clap = { version = "4.5.54", features = ["derive"], optional = true } regex = { version = "1.12.2", optional = true } +ctrlc = { version = "3.5.1", optional = true } # --- Coroutine --- num_cpus = { version = "1.17.0", optional = true } @@ -61,7 +62,7 @@ version = "1.49.0" features = [ "macros", "rt-multi-thread", "time" ] [features] -default = [] +default = ["full"] full = [ "cli", "coroutine-heavy", @@ -75,7 +76,7 @@ full = [ # --- Command Line Interface --- print = ["dep:oneshot", "dep:regex", "dep:env_filter", "dep:terminal_size", "dep:unicode-width"] -cli = ["dep:clap", "print"] +cli = ["dep:clap", "print", "ctrlc"] prompt = ["print"] prompt-password = ["prompt"] diff --git a/packages/copper/src/atomic.rs b/packages/copper/src/atomic.rs index 68f7435..6c42bdd 100644 --- a/packages/copper/src/atomic.rs +++ b/packages/copper/src/atomic.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + /// An atomic wrapper with an underlying atomic storage and conversion to /// a type T. /// @@ -78,3 +80,8 @@ impl_atomic_type! { isize => AtomicIsize, new_isize, usize => AtomicUsize, new_usize, } + +pub(crate) fn next_atomic_usize() -> usize { + static ID: AtomicUsize = AtomicUsize::new(1); + ID.fetch_add(1, Ordering::SeqCst) +} diff --git a/packages/copper/src/cli/ctrlc.rs b/packages/copper/src/cli/ctrlc.rs new file mode 100644 index 0000000..990d6a0 --- /dev/null +++ b/packages/copper/src/cli/ctrlc.rs @@ -0,0 +1,200 @@ +use std::{sync::{Arc, LazyLock, Mutex, TryLockError, TryLockResult, atomic::{AtomicBool, AtomicU8, Ordering}}, thread::JoinHandle, time::Duration}; + + +use spin::Mutex as SpinMutex; + + +static CTRLC_HANDLERS_NEW: Mutex>> = Mutex::new(Vec::new()); +static CTRLC_SIGNAL_STACK: Mutex> = Mutex::new(Vec::new()); +static INIT_ONCE: LazyLock> = LazyLock::new(|| { + let mut handlers = vec![]; + let set_result = ctrlc::try_set_handler(move || { + // populate new handlers + if let Ok(mut new_handlers) = CTRLC_HANDLERS_NEW.lock() { + handlers.extend(new_handlers.drain(..)); + } + // signal the stack + if let Ok(stack) = CTRLC_SIGNAL_STACK.lock() { + if let Some(frame) = stack.last() { + frame.signal(); + } + } + + // note we are not holding any lock when invoking user-defined handlers + for handler in handlers.iter_mut().rev() { + handler(); + } + + }); + match set_result { + Err(ctrlc::Error::MultipleHandlers) => { + Err("failed to set ctrl-c handler: a handler is already set using the `ctrlc` crate. please set with cu::cli instead (see documentation for more information)".to_string()) + } + Err(other_error) => { + Err(format!("failed to set ctrl-c handler: {other_error}")) + } + Ok(_) => Ok(()) + } +}); + +/// Add a global handler to handle Ctrl-C signals +/// +/// +/// +pub fn add_global_ctrlc_handler(handler: F) -> cu::Result<()> { + { + let Ok(mut handlers) = CTRLC_HANDLERS_NEW.lock() else { + cu::bail!("global ctrl-c handler vector is poisoned"); + }; + handlers.push(Box::new(handler)); + } + if let Err(e) = &*INIT_ONCE { + cu::bail!("{e}"); + } + Ok(()) +} + +/// # Handling Ctrl-C +/// +/// The [`ctrlc`](https://docs.rs/ctrlc) crate provides a low-level cross-platform +/// way to set up a handler for `Ctrl-C`. `cu` builds wrappers around it to provide +/// a better experience handling user interrupts. +/// +/// # Action Frames +/// The `catch_ctrlc` function (this function) does the following: +/// - Push a new frame in the global Ctrl-C stack. +/// - Start executing the request in a new thread +/// - Block the current thread until either: +/// 1. the task thread finishes and returns a result, or +/// 2. the ctrl-c signal is received +/// +/// The closure receives a signal object where it can use to check if +/// termination is requested, so it can terminate the thread. +/// the thread will be joined before returning. +/// +/// Since this blocks the current thread, use `co_catch_ctrlc` in async contexts +/// +/// If it's signalled then this function is guaranteed to return Ok(None), +/// but the closure may or may not have executed. +/// if there are error registering ctrolc handler, we will execute +/// the closure without the ability to interrupt it +/// +/// the closure is responsible for checking if it is interrupted and return +/// +pub fn catch_ctrlc(f: F) -> cu::Result> +where + T: 'static + Send, +F: FnOnce(CtrlcSignal) -> cu::Result + 'static + Send +{ + let signal = CtrlcSignal::new(crate::next_atomic_usize()); + { + let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { + cu::trace!("failed to register new ctrl-c frame, will run synchronously"); + return f(signal).map(Some); + }; + signal_stack.push(signal.clone()); + } + + let _drop_scope = CtrlcScope(signal.id); + + let handle = { + let signal = signal.clone(); + std::thread::spawn(move || { + // after the thread has spawned, check if it's already cancelled + if signal.signaled() { + return None; + } + Some(f(signal)) + }) + }; + if let Err(e) = &*INIT_ONCE { + cu::bail!("{e}"); + } + // poll for join + while !handle.is_finished() { + if signal.signaled() { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(200)); + } + if signal.signaled() { + return Ok(None); + } + match handle.join() { + Err(e) => { + cu::bail!("failed to join ctrl-c frame: {}", crate::best_effort_panic_info(&e)); + } + Ok(None) => Ok(None) , + Ok(Some(v)) => Ok(Some(v?)), + } + +} + +pub async fn co_catch_ctrlc(f: F) -> cu::Result> +where + TFuture: Future>, +F: FnOnce(CtrlcSignal) -> TFuture, + T: Send +{ + let signal = CtrlcSignal::new(crate::next_atomic_usize()); + { + let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { + cu::trace!("failed to register new ctrl-c frame, will run synchronously"); + return f(signal).await.map(Some); + }; + signal_stack.push(signal.clone()); + } + + let _drop_scope = CtrlcScope(signal.id); + let handle = { + let signal = signal.clone(); + cu::co::spawn(async move { + // after the thread has spawned, check if it's already cancelled + if signal.signaled() { + return None; + } + Some(f(signal).await) + }) + }; + if let Err(e) = &*INIT_ONCE { + cu::bail!("{e}"); + } + tokio::select! { + result = handle.co_join_maybe_aborted().await => { + } + } +} + +#[derive(Clone)] +struct CtrlcSignal { + id: usize, + signaled_times: Arc +} +impl CtrlcSignal { + fn new(id: usize) -> Self { + Self { id, signaled_times: Arc::new(AtomicU8::new(0)) } + } + pub fn check_signaled(&self) -> cu::Result<()> { + if self.signaled() { + cu::bail!("interrupted") + } + Ok(()) + } + pub fn signaled(&self) -> bool { + self.signaled_times.load(Ordering::Acquire) > 0 + } + pub fn signaled_times(&self) -> u8 { + self.signaled_times.load(Ordering::Acquire) + } + pub fn signal(&self) { + self.signaled_times.fetch_add(1, Ordering::AcqRel); + } +} +struct CtrlcScope(usize); +impl Drop for CtrlcScope { + fn drop(&mut self) { + if let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() { + signal_stack.retain(|x| x.id != self.0); + } + } +} diff --git a/packages/copper/src/cli/mod.rs b/packages/copper/src/cli/mod.rs index 575cb54..17b9f1e 100644 --- a/packages/copper/src/cli/mod.rs +++ b/packages/copper/src/cli/mod.rs @@ -159,6 +159,8 @@ mod password; #[cfg(feature = "prompt-password")] pub use password::password_chars_legal; +mod ctrlc; + /// Formatting utils pub(crate) mod fmt; diff --git a/packages/copper/src/cli/progress/builder.rs b/packages/copper/src/cli/progress/builder.rs index 55616a6..f7fa0d6 100644 --- a/packages/copper/src/cli/progress/builder.rs +++ b/packages/copper/src/cli/progress/builder.rs @@ -193,7 +193,7 @@ impl ProgressBarBuilder { None }; let state_immut = StateImmut { - id: next_id(), + id: crate::next_atomic_usize(), parent: self.parent.as_ref().map(Arc::clone), prefix: self.message, done_message, @@ -210,7 +210,3 @@ impl ProgressBarBuilder { } } -fn next_id() -> usize { - static ID: AtomicUsize = AtomicUsize::new(1); - ID.fetch_add(1, Ordering::SeqCst) -} From 4dad6599984ac49a174e258310f532deb60c26a5 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Thu, 15 Jan 2026 00:33:58 -0800 Subject: [PATCH 2/5] documentation for ctrlc --- packages/copper/examples/ctrlc.rs | 45 +++++++++ packages/copper/src/cli/ctrlc.rs | 154 +++++++++++++++++++++++++++--- packages/copper/src/cli/mod.rs | 5 + packages/copper/src/co/handle.rs | 5 + packages/copper/src/co/mod.rs | 2 +- packages/copper/src/lib.rs | 1 + 6 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 packages/copper/examples/ctrlc.rs diff --git a/packages/copper/examples/ctrlc.rs b/packages/copper/examples/ctrlc.rs new file mode 100644 index 0000000..bc93760 --- /dev/null +++ b/packages/copper/examples/ctrlc.rs @@ -0,0 +1,45 @@ +use pistonite_cu as cu; +use std::time::Duration; + +#[cu::cli] +fn main(_: cu::cli::Flags) -> cu::Result<()> { + let check = cu::yesno!("should the sync version check for the signal?")?; + sync_main(check)?; + cu::co::run(async move {async_main().await})?; + Ok(()) +} + +fn sync_main(check: bool) -> cu::Result<()> { + match cu::cli::catch_ctrlc(move |ctrlc| { + for _ in 0..30 { + cu::print!("(sync) please press Ctrl-C"); + std::thread::sleep(Duration::from_millis(100)); + if check { + ctrlc.check()?; + } + } + cu::warn!("(sync) about to return!"); + cu::Ok(42) + }){ + Ok(None) => cu::info!("(sync) was aborted!"), + Ok(Some(n)) => cu::info!("(sync) was finished: {n}"), + Err(e) => cu::error!("(sync) error: {e:?}"), + } + Ok(()) +} + +async fn async_main() -> cu::Result<()> { + match cu::cli::co_catch_ctrlc(async |_| { + for _ in 0..10 { + cu::print!("(async) please press Ctrl-C"); + cu::co::sleep(Duration::from_secs(1)).await; + } + cu::warn!("(async) about to return!"); + cu::Ok(42) + }).await { + Ok(None) => cu::info!("(async) was aborted!"), + Ok(Some(n)) => cu::info!("(async) was finished: {n}"), + Err(e) => cu::error!("(async) error: {e:?}"), + } + Ok(()) +} diff --git a/packages/copper/src/cli/ctrlc.rs b/packages/copper/src/cli/ctrlc.rs index 990d6a0..567bb82 100644 --- a/packages/copper/src/cli/ctrlc.rs +++ b/packages/copper/src/cli/ctrlc.rs @@ -1,11 +1,13 @@ -use std::{sync::{Arc, LazyLock, Mutex, TryLockError, TryLockResult, atomic::{AtomicBool, AtomicU8, Ordering}}, thread::JoinHandle, time::Duration}; - - -use spin::Mutex as SpinMutex; - +use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::Duration; +/// New handlers that will be added by the ctrlc thread +/// once ctrlc is pressed static CTRLC_HANDLERS_NEW: Mutex>> = Mutex::new(Vec::new()); +/// Stack of CtrlC frames to signal static CTRLC_SIGNAL_STACK: Mutex> = Mutex::new(Vec::new()); +/// Thread safe init lock static INIT_ONCE: LazyLock> = LazyLock::new(|| { let mut handlers = vec![]; let set_result = ctrlc::try_set_handler(move || { @@ -41,6 +43,7 @@ static INIT_ONCE: LazyLock> = LazyLock::new(|| { /// /// /// +#[cfg(feature = "cli")] pub fn add_global_ctrlc_handler(handler: F) -> cu::Result<()> { { let Ok(mut handlers) = CTRLC_HANDLERS_NEW.lock() else { @@ -61,6 +64,68 @@ pub fn add_global_ctrlc_handler(handler: F) -> cu:: /// a better experience handling user interrupts. /// /// # Action Frames +/// Most of the time, custom Ctrl-C behavior is for executing some long running +/// tasks and give the user the ability to abort it. `cu` maintains a stack +/// of these "action frames" that are created using `cu::cli::catch_ctrlc` +/// or the async version [`cu::cli::co_catch_ctrlc`]. The sync and async versions +/// have slightly different behavior, but the spirit is the same: +/// +/// - The action is executed on a different thread (or asynchronously, in the async version) +/// - When user hits `Ctrl-C`, the top-most action frame on the stack is signalled +/// - The calling thread/task polls for the `Ctrl-C` signal, if signalled, +/// it will return. +/// - The action frame is popped when the +/// +/// The return value of the inner `cu::Result` will be transformed into +/// `cu::Result>`, where: +/// - `Ok(Some(value))`: the action was never interrupted by `Ctrl-C` +/// - `Ok(None)`: the action was interrupted by `Ctrl-C` +/// - `Err(e)`: either the inner task returned error, or some other error happened in the framework +/// (for example, when joining the task thread/future) +/// +/// # Behavior +/// Since the calling thread is the one that polls the signal, the caller will be quickly +/// unblocked once the signal happens, and return `Ok(None)`. However, Rust does not +/// give us a way to "kill" the thread that is running the task. The task closure +/// receives an input `CtrlSignal` that it can use by periodically checking +/// if the task has been aborted +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// use std::thread; +/// use std::time::Duration; +/// +/// match cu::cli::catch_ctrlc(|ctrlc| { +/// for _ in 0..10 { +/// cu::print!("please press Ctrl-C"); +/// thread::sleep(Duration::from_secs(2)); +/// ctrlc.check?; // returns Err if signaled +/// } +/// cu::Ok(42) +/// }) { +/// Ok(None) => cu::info!("was aborted!"), +/// Ok(Some(n)) => cu::info!("was finished: {n}"), +/// Err(e) => cu::error!("error: {e:?}"), +/// } +/// ``` +/// +/// Because we use polling and not a blocking join, `catch_ctrlc` can be used +/// even in async context. For the version that uses the async runtime, see [`co_catch_ctrlc`]. +/// +/// # Fallback +/// If the `Ctrl-C` framework failed to initialize, this may fallback to simply running +/// the task on the current thread, which will make it not abortable. +/// +/// # Global Handlers +/// Action frames should be used whenever possible. However, if a global handler is +/// needed, +/// Since the `ctrlc` crate only allows one global handler, and `cu` has registered it, +/// you need to use [`cu::cli::add_global_ctrlc_handler`] to register your global handlers. +/// +/// Note that: +/// - Global handlers cannot be unregistered +/// +/// +/// # Action Frames /// The `catch_ctrlc` function (this function) does the following: /// - Push a new frame in the global Ctrl-C stack. /// - Start executing the request in a new thread @@ -130,16 +195,54 @@ F: FnOnce(CtrlcSignal) -> cu::Result + 'static + Send } +/// Async version of [`catch_ctrlc`]. +/// +/// For how the `Ctrl-C` framework works, please refer to [documentation](catch_ctrlc) +/// +/// # Behavior +/// There are 2 main differences between this and the synchronous version: +/// 1. This does not spawn a dedicated thread. Instead, it's up to the async +/// runtime to execute the task. +/// 2. Even if the task does not check for the signal, the `Ctrl-C` signal +/// will cause the future to be dropped. In async terms, that means the future +/// will not be polled again if it yields back to the runtime (i.e. it +/// will be aborted) +/// +/// In the example below, even we never check the signal, the warning +/// will not print if `Ctrl-C` was pressed in time. +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use std::time::Duration; +/// +/// match cu::cli::co_catch_ctrlc(async |_| { +/// for _ in 0..10 { +/// cu::print!("please press Ctrl-C"); +/// cu::co::sleep(Duration::from_secs(2)).await; +/// } +/// cu::warn!("did not abort!"); +/// cu::Ok(42) +/// }).await { +/// Ok(None) => cu::info!("was aborted!"), +/// Ok(Some(n)) => cu::info!("was finished: {n}"), +/// Err(e) => cu::error!("error: {e:?}"), +/// } +/// ``` +/// +/// # Fallback +/// If the `Ctrl-C` framework failed to initialize, this may fallback to simply `.await`-ing +/// the task, which will make it not respond to `Ctrl-C` signals +/// +#[cfg(feature = "coroutine")] pub async fn co_catch_ctrlc(f: F) -> cu::Result> where - TFuture: Future>, -F: FnOnce(CtrlcSignal) -> TFuture, - T: Send + TFuture: Future> + Send + 'static, +F: FnOnce(CtrlcSignal) -> TFuture + Send + 'static, + T: Send + 'static { let signal = CtrlcSignal::new(crate::next_atomic_usize()); { let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { - cu::trace!("failed to register new ctrl-c frame, will run synchronously"); + cu::trace!("failed to register new ctrl-c frame, will simply await"); return f(signal).await.map(Some); }; signal_stack.push(signal.clone()); @@ -159,14 +262,36 @@ F: FnOnce(CtrlcSignal) -> TFuture, if let Err(e) = &*INIT_ONCE { cu::bail!("{e}"); } + let sleep_future = + async { + loop { + tokio::time::sleep(Duration::from_millis(200)).await; + if signal.signaled() { + break; + } + } + }; tokio::select! { - result = handle.co_join_maybe_aborted().await => { + result = handle.co_join() => { + if signal.signaled() { + return Ok(None); + } + match result { + Err(e) => { + cu::bail!("failed to join ctrl-c frame: {e}"); + } + Ok(None) => Ok(None), + Ok(Some(v)) => Ok(Some(v?)), + } + } + _ = sleep_future => { + Ok(None) } } } #[derive(Clone)] -struct CtrlcSignal { +pub struct CtrlcSignal { id: usize, signaled_times: Arc } @@ -174,18 +299,23 @@ impl CtrlcSignal { fn new(id: usize) -> Self { Self { id, signaled_times: Arc::new(AtomicU8::new(0)) } } - pub fn check_signaled(&self) -> cu::Result<()> { + /// Return an `Err` if `Ctrl-C` has been signaled + pub fn check(&self) -> cu::Result<()> { if self.signaled() { cu::bail!("interrupted") } Ok(()) } + /// Return `true` if `Ctrl-C` has been signaled pub fn signaled(&self) -> bool { self.signaled_times.load(Ordering::Acquire) > 0 } + /// Get the number of times `Ctrl-C` has been signaled pub fn signaled_times(&self) -> u8 { self.signaled_times.load(Ordering::Acquire) } + /// Programmatically trigger the signal. Note that this does not + /// send actual signal or keyboard events pub fn signal(&self) { self.signaled_times.fetch_add(1, Ordering::AcqRel); } diff --git a/packages/copper/src/cli/mod.rs b/packages/copper/src/cli/mod.rs index 17b9f1e..14949a7 100644 --- a/packages/copper/src/cli/mod.rs +++ b/packages/copper/src/cli/mod.rs @@ -160,6 +160,11 @@ mod password; pub use password::password_chars_legal; mod ctrlc; +pub use ctrlc::{catch_ctrlc, CtrlcSignal}; +#[cfg(feature = "cli")] +pub use ctrlc::add_global_ctrlc_handler; +#[cfg(feature = "coroutine")] +pub use ctrlc::co_catch_ctrlc; /// Formatting utils pub(crate) mod fmt; diff --git a/packages/copper/src/co/handle.rs b/packages/copper/src/co/handle.rs index f920ae6..ae473f5 100644 --- a/packages/copper/src/co/handle.rs +++ b/packages/copper/src/co/handle.rs @@ -16,6 +16,11 @@ impl Handle { self.into() } + /// Test if the task has finished and is ready to be joined + pub fn is_finished(&self) -> bool { + self.0.is_finished() + } + /// Abort the task, trying to `join` or `co_join` an aborted /// task (if it's not already completed) will return an error indicating /// it's already aborted. diff --git a/packages/copper/src/co/mod.rs b/packages/copper/src/co/mod.rs index d1e8841..2de45ba 100644 --- a/packages/copper/src/co/mod.rs +++ b/packages/copper/src/co/mod.rs @@ -72,7 +72,7 @@ //! ``` // re-exports -pub use tokio::{join, select, try_join}; +pub use tokio::{join, select, try_join, time::sleep}; mod runtime; #[cfg(not(feature = "coroutine-heavy"))] diff --git a/packages/copper/src/lib.rs b/packages/copper/src/lib.rs index 85e7b44..4eb3e5c 100644 --- a/packages/copper/src/lib.rs +++ b/packages/copper/src/lib.rs @@ -62,6 +62,7 @@ //! - [Logging](mod@crate::lv) (via [`log`](https://docs.rs/log)) //! - [Printing and Command Line Interface](mod@crate::cli) (CLI arg parsing via //! [`clap`](https://docs.rs/clap)) +//! - [Handling Ctrl-C](fn@crate::cli::catch_ctrlc) //! - [Progress Bars](fn@crate::progress) //! - [Prompting](macro@crate::prompt) //! - [Coroutines (Async)](mod@crate::co) (via [`tokio`](https://docs.rs/tokio)) From 72253b3baf81b341cb5c6c0c91ea52438773db2a Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 17 Jan 2026 13:53:01 -0800 Subject: [PATCH 3/5] feat(cli): Ctrl-C handler --- Taskfile.yml | 2 +- packages/copper/Cargo.toml | 14 +- packages/copper/examples/ctrlc.rs | 91 ++++- packages/copper/src/atomic.rs | 1 + packages/copper/src/cli/ctrlc.rs | 421 ++++++++++---------- packages/copper/src/cli/mod.rs | 4 +- packages/copper/src/cli/progress/builder.rs | 2 - packages/copper/src/co/mod.rs | 2 +- packages/copper/src/errhand.rs | 25 +- packages/copper/src/lib.rs | 2 +- 10 files changed, 326 insertions(+), 238 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index e905d7e..e3102f2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,7 +52,7 @@ tasks: doc: cmds: # note: require nightly toolchain in CI - - cargo doc --no-deps --features full,nightly + - cargo doc --no-deps --features full,nightly -p pistonite_cu,pistonite_cu_proc_macros,pistonite_pm - cmd: > echo "Generating index redirect"; echo "" > target/doc/index.html; diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index 9e90689..1c77a3b 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -62,7 +62,7 @@ version = "1.49.0" features = [ "macros", "rt-multi-thread", "time" ] [features] -default = ["full"] +default = [] full = [ "cli", "coroutine-heavy", @@ -75,15 +75,16 @@ full = [ ] # --- Command Line Interface --- -print = ["dep:oneshot", "dep:regex", "dep:env_filter", "dep:terminal_size", "dep:unicode-width"] -cli = ["dep:clap", "print", "ctrlc"] +print = ["dep:oneshot", "dep:regex", "dep:env_filter", "dep:terminal_size", "dep:unicode-width", "dep:ctrlc"] +cli = ["dep:clap", "print"] prompt = ["print"] prompt-password = ["prompt"] # --- Coroutine --- coroutine = [ "dep:tokio", "dep:num_cpus", - "tokio/sync", "tokio/io-util", "tokio/io-std" + "tokio/sync", "tokio/io-util", "tokio/io-std", + "tokio/time", ] coroutine-heavy = ["coroutine"] # enable heavy coroutine drived by multi-threaded tokio runtime @@ -92,7 +93,6 @@ process = [ # enable spawning child processes "coroutine", "fs", "dep:spin", "tokio/process", - "tokio/time", ] fs = [ # enable file system and path util "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", @@ -135,3 +135,7 @@ required-features = ["fs", "cli"] [[example]] name = "cargo" required-features = ["process", "cli", "json"] + +[[example]] +name = "ctrlc" +required-features = ["cli", "coroutine"] diff --git a/packages/copper/examples/ctrlc.rs b/packages/copper/examples/ctrlc.rs index bc93760..ea30986 100644 --- a/packages/copper/examples/ctrlc.rs +++ b/packages/copper/examples/ctrlc.rs @@ -3,24 +3,23 @@ use std::time::Duration; #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { - let check = cu::yesno!("should the sync version check for the signal?")?; - sync_main(check)?; - cu::co::run(async move {async_main().await})?; + sync_main()?; + cu::co::run(async move { async_auto_check().await })?; + cu::co::run(async move { async_manual_check().await })?; Ok(()) } -fn sync_main(check: bool) -> cu::Result<()> { - match cu::cli::catch_ctrlc(move |ctrlc| { +fn sync_main() -> cu::Result<()> { + let result = cu::cli::ctrlc_frame().execute(move |ctrlc| { for _ in 0..30 { cu::print!("(sync) please press Ctrl-C"); std::thread::sleep(Duration::from_millis(100)); - if check { - ctrlc.check()?; - } + ctrlc.check()?; } cu::warn!("(sync) about to return!"); cu::Ok(42) - }){ + }); + match result { Ok(None) => cu::info!("(sync) was aborted!"), Ok(Some(n)) => cu::info!("(sync) was finished: {n}"), Err(e) => cu::error!("(sync) error: {e:?}"), @@ -28,18 +27,74 @@ fn sync_main(check: bool) -> cu::Result<()> { Ok(()) } -async fn async_main() -> cu::Result<()> { - match cu::cli::co_catch_ctrlc(async |_| { - for _ in 0..10 { - cu::print!("(async) please press Ctrl-C"); - cu::co::sleep(Duration::from_secs(1)).await; - } - cu::warn!("(async) about to return!"); - cu::Ok(42) - }).await { +async fn async_manual_check() -> cu::Result<()> { + let result = cu::cli::ctrlc_frame() + .abort_threshold(3) + .on_signal(|ctrlc| { + if !ctrlc.should_abort() { + cu::warn!("Ctrl-C 3 times to abort!") + } + }) + .co_execute(async |ctrlc| { + for _ in 0..10 { + cu::print!("(async) please press Ctrl-C"); + cu::co::sleep(Duration::from_secs(1)).await; + if ctrlc.should_abort() { + cu::info!("just kidding, we never abort"); + } + } + cu::warn!("(async) about to return!"); + cu::Ok(42) + }) + .await; + match result { Ok(None) => cu::info!("(async) was aborted!"), Ok(Some(n)) => cu::info!("(async) was finished: {n}"), Err(e) => cu::error!("(async) error: {e:?}"), } Ok(()) } + +async fn async_auto_check() -> cu::Result<()> { + let (send, mut recv) = tokio::sync::mpsc::unbounded_channel(); + let result = cu::cli::ctrlc_frame() + .on_signal(move |_| { + let _ = send.send(()); + }) + .co_execute(async move |ctrlc| { + let waiter = async move { + loop { + if recv.recv().await.is_none() { + return; + } + if ctrlc.should_abort() { + return; + } + } + }; + tokio::select! { + result = my_long_running_task() => { + result + } + _ = waiter => { + // does not matter what the value is - + // co_execute will ensure None is returned + // when aborted + Ok(0) + } + } + }) + .await?; + match result { + Some(x) => cu::info!("valud is: {x}"), + None => cu::error!("aborted!"), + } + Ok(()) +} + +async fn my_long_running_task() -> cu::Result { + loop { + cu::print!("will run forever if you don't abort"); + cu::co::sleep(Duration::from_secs(1)).await; + } +} diff --git a/packages/copper/src/atomic.rs b/packages/copper/src/atomic.rs index 6c42bdd..e5b462b 100644 --- a/packages/copper/src/atomic.rs +++ b/packages/copper/src/atomic.rs @@ -81,6 +81,7 @@ impl_atomic_type! { usize => AtomicUsize, new_usize, } +#[allow(unused)] pub(crate) fn next_atomic_usize() -> usize { static ID: AtomicUsize = AtomicUsize::new(1); ID.fetch_add(1, Ordering::SeqCst) diff --git a/packages/copper/src/cli/ctrlc.rs b/packages/copper/src/cli/ctrlc.rs index 567bb82..1e85275 100644 --- a/packages/copper/src/cli/ctrlc.rs +++ b/packages/copper/src/cli/ctrlc.rs @@ -1,12 +1,11 @@ -use std::sync::{Arc, LazyLock, Mutex}; use std::sync::atomic::{AtomicU8, Ordering}; -use std::time::Duration; +use std::sync::{Arc, LazyLock, Mutex}; /// New handlers that will be added by the ctrlc thread /// once ctrlc is pressed -static CTRLC_HANDLERS_NEW: Mutex>> = Mutex::new(Vec::new()); +static CTRLC_HANDLERS_NEW: Mutex>> = Mutex::new(Vec::new()); /// Stack of CtrlC frames to signal -static CTRLC_SIGNAL_STACK: Mutex> = Mutex::new(Vec::new()); +static CTRLC_SIGNAL_STACK: Mutex> = Mutex::new(Vec::new()); /// Thread safe init lock static INIT_ONCE: LazyLock> = LazyLock::new(|| { let mut handlers = vec![]; @@ -16,18 +15,27 @@ static INIT_ONCE: LazyLock> = LazyLock::new(|| { handlers.extend(new_handlers.drain(..)); } // signal the stack + let mut signalled = false; if let Ok(stack) = CTRLC_SIGNAL_STACK.lock() { if let Some(frame) = stack.last() { - frame.signal(); + signalled = true; + frame.signal.signal(); + if let Some(f) = &frame.on_signal { + f(frame.signal.clone()) + } } } // note we are not holding any lock when invoking user-defined handlers + + // if user did not set any global handler or action frames, then we terminate + if !signalled && handlers.is_empty() { + std::process::exit(1); + } for handler in handlers.iter_mut().rev() { handler(); } - - }); + }); match set_result { Err(ctrlc::Error::MultipleHandlers) => { Err("failed to set ctrl-c handler: a handler is already set using the `ctrlc` crate. please set with cu::cli instead (see documentation for more information)".to_string()) @@ -41,8 +49,10 @@ static INIT_ONCE: LazyLock> = LazyLock::new(|| { /// Add a global handler to handle Ctrl-C signals /// +/// See [Handling Ctrl-C](fn@crate::cli::ctrlc_frame). /// -/// +/// Note that this is only available with feature `cli` - since +/// you should not be adding a global handler from a library. #[cfg(feature = "cli")] pub fn add_global_ctrlc_handler(handler: F) -> cu::Result<()> { { @@ -60,45 +70,94 @@ pub fn add_global_ctrlc_handler(handler: F) -> cu:: /// # Handling Ctrl-C /// /// The [`ctrlc`](https://docs.rs/ctrlc) crate provides a low-level cross-platform -/// way to set up a handler for `Ctrl-C`. `cu` builds wrappers around it to provide -/// a better experience handling user interrupts. +/// way to set up a handler for `Ctrl-C`. `cu` builds wrappers around it so it can +/// be intergrated with other internals, such as prompts. /// /// # Action Frames /// Most of the time, custom Ctrl-C behavior is for executing some long running -/// tasks and give the user the ability to abort it. `cu` maintains a stack -/// of these "action frames" that are created using `cu::cli::catch_ctrlc` -/// or the async version [`cu::cli::co_catch_ctrlc`]. The sync and async versions -/// have slightly different behavior, but the spirit is the same: +/// tasks and give the user the ability to abort it. +/// The [`execute`](CtrlcBuilder::execute) function pushes a new frame +/// to an internal stack, then executes the task synchronously on the same thread. +/// **The task itself is responsible for periodically checking the received +/// signal object if it has been aborted (by calling `ctrlc.check?`). +/// +/// The frame will only be removed from the stack when the task returns. +/// Note that different threads can spawn Ctrl-C frames, and those frame +/// may not finish in order. When user hits `Ctrl-C`, only the most recently +/// added frame (the top-most of the stack) will be notified. +/// It will continue to notify the frame until the task ends and removes +/// the frame from the stask. +/// +/// # Async Behavior +/// The async version of the API, [`co_execute`](CtrlcBuilder::co_execute), +/// is exactly the same, other than it takes an async closure instead. +/// Even though in the async case, we can let the runtime drop the future +/// to abort it without explicit checks, the explicit checks make it clearer +/// and easier to reason about program states when aborting. +/// +/// If you prefer automatically cancellation, consider this pattern: +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// # async fn main_() -> cu::Result<()> { +/// let (send, mut recv) = tokio::sync::mpsc::unbounded_channel(); +/// let result = cu::cli::ctrlc_frame() +/// .on_signal(move |_| { let _ = send.send(()); }) +/// .co_execute(async move |ctrlc| { +/// let waiter = async move { +/// loop { +/// if recv.recv().await.is_none() { +/// return; +/// } +/// if ctrlc.should_abort() { +/// return; +/// } +/// } +/// }; +/// tokio::select! { +/// result = my_long_running_task() => { +/// return result; +/// } +/// _ = waiter => { +/// // does not matter what the value is - +/// // co_execute will ensure None is returned +/// // when aborted +/// return Ok(0); +/// } +/// } +/// }).await?; +/// match result { +/// Some(x) => cu::info!("valud is: {x}"), +/// None => cu::error!("aborted!"), +/// } +/// # Ok(()) } /// -/// - The action is executed on a different thread (or asynchronously, in the async version) -/// - When user hits `Ctrl-C`, the top-most action frame on the stack is signalled -/// - The calling thread/task polls for the `Ctrl-C` signal, if signalled, -/// it will return. -/// - The action frame is popped when the +/// async fn my_long_running_task() -> cu::Result { +/// // your task here can get aborted without checking the signal +/// // which can be dangerous and have unintended effects! +/// Ok(42) +/// } +/// ``` /// +/// # Return Value /// The return value of the inner `cu::Result` will be transformed into /// `cu::Result>`, where: -/// - `Ok(Some(value))`: the action was never interrupted by `Ctrl-C` -/// - `Ok(None)`: the action was interrupted by `Ctrl-C` +/// - `Ok(Some(value))`: the action was not aborted, and produced a value. +/// - `Ok(None)`: the action was aborted. +/// - The definition of "aborted" can be customized by setting the [`abort_threshold()`](CtrlcBuilder::abort_threshold) +/// on the builder /// - `Err(e)`: either the inner task returned error, or some other error happened in the framework /// (for example, when joining the task thread/future) /// -/// # Behavior -/// Since the calling thread is the one that polls the signal, the caller will be quickly -/// unblocked once the signal happens, and return `Ok(None)`. However, Rust does not -/// give us a way to "kill" the thread that is running the task. The task closure -/// receives an input `CtrlSignal` that it can use by periodically checking -/// if the task has been aborted /// ```rust,no_run /// # use pistonite_cu as cu; /// use std::thread; /// use std::time::Duration; /// -/// match cu::cli::catch_ctrlc(|ctrlc| { +/// match cu::cli::ctrlc_frame().execute(|ctrlc| { /// for _ in 0..10 { /// cu::print!("please press Ctrl-C"); /// thread::sleep(Duration::from_secs(2)); -/// ctrlc.check?; // returns Err if signaled +/// ctrlc.check()?; // returns Err if signaled /// } /// cu::Ok(42) /// }) { @@ -108,207 +167,165 @@ pub fn add_global_ctrlc_handler(handler: F) -> cu:: /// } /// ``` /// -/// Because we use polling and not a blocking join, `catch_ctrlc` can be used -/// even in async context. For the version that uses the async runtime, see [`co_catch_ctrlc`]. -/// /// # Fallback /// If the `Ctrl-C` framework failed to initialize, this may fallback to simply running -/// the task on the current thread, which will make it not abortable. +/// the task without the ability to receive signals. /// /// # Global Handlers -/// Action frames should be used whenever possible. However, if a global handler is -/// needed, -/// Since the `ctrlc` crate only allows one global handler, and `cu` has registered it, -/// you need to use [`cu::cli::add_global_ctrlc_handler`] to register your global handlers. -/// -/// Note that: -/// - Global handlers cannot be unregistered -/// -/// -/// # Action Frames -/// The `catch_ctrlc` function (this function) does the following: -/// - Push a new frame in the global Ctrl-C stack. -/// - Start executing the request in a new thread -/// - Block the current thread until either: -/// 1. the task thread finishes and returns a result, or -/// 2. the ctrl-c signal is received +/// Action frames should be used whenever possible. +/// If you do need a global handler for `Ctrl-C`, use +/// [`cu::cli::add_global_ctrlc_handler`]. This is because the `ctrlc` crate +/// only allows one global handler. /// -/// The closure receives a signal object where it can use to check if -/// termination is requested, so it can terminate the thread. -/// the thread will be joined before returning. -/// -/// Since this blocks the current thread, use `co_catch_ctrlc` in async contexts -/// -/// If it's signalled then this function is guaranteed to return Ok(None), -/// but the closure may or may not have executed. -/// if there are error registering ctrolc handler, we will execute -/// the closure without the ability to interrupt it -/// -/// the closure is responsible for checking if it is interrupted and return -/// -pub fn catch_ctrlc(f: F) -> cu::Result> -where - T: 'static + Send, -F: FnOnce(CtrlcSignal) -> cu::Result + 'static + Send -{ - let signal = CtrlcSignal::new(crate::next_atomic_usize()); - { - let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { - cu::trace!("failed to register new ctrl-c frame, will run synchronously"); - return f(signal).map(Some); - }; - signal_stack.push(signal.clone()); - } - - let _drop_scope = CtrlcScope(signal.id); +/// Global handlers are called after the action frame is notified (if any), +/// and called in reverse order of registration. +/// The underlying handler is lazily set up whenever a global handler +/// or action frame is added. If there are no longer any action frames +/// and there are no global handlers, the underlying handler +/// will call `std::process::exit(1)` to terminate. +#[inline(always)] +pub fn ctrlc_frame() -> CtrlcBuilder { + CtrlcBuilder::default() +} - let handle = { - let signal = signal.clone(); - std::thread::spawn(move || { - // after the thread has spawned, check if it's already cancelled - if signal.signaled() { - return None; - } - Some(f(signal)) - }) - }; - if let Err(e) = &*INIT_ONCE { - cu::bail!("{e}"); - } - // poll for join - while !handle.is_finished() { - if signal.signaled() { - return Ok(None); +/// Builder for a new frame for handling `Ctrl-C` signals. +/// The canonical factory function for this is `cu::cli::ctrlc_frame`. +/// See [Handling Ctrl-C](ctrlc_frame). +pub struct CtrlcBuilder { + abort_threshold: u8, + on_signal: Option, +} +impl Default for CtrlcBuilder { + #[inline(always)] + fn default() -> Self { + Self { + abort_threshold: 1, + on_signal: None, } - std::thread::sleep(Duration::from_millis(200)); - } - if signal.signaled() { - return Ok(None); } - match handle.join() { - Err(e) => { - cu::bail!("failed to join ctrl-c frame: {}", crate::best_effort_panic_info(&e)); - } - Ok(None) => Ok(None) , - Ok(Some(v)) => Ok(Some(v?)), +} +impl CtrlcBuilder { + /// Set the number of `Ctrl-C` signals required for the task + /// to be considered aborted (where the return will be `Ok(None`). + /// + /// Default is 1 + #[inline(always)] + pub fn abort_threshold(mut self, threshold: u8) -> Self { + self.abort_threshold = threshold; + self } -} + /// Set a function to be called when `Ctrl-C` signal is received. + /// + /// The function will be executed on the `Ctrl-C` signal handling thread, + /// not the thread that runs the task, and is + #[inline(always)] + pub fn on_signal(mut self, f: F) -> Self { + self.on_signal = Some(Box::new(f)); + self + } -/// Async version of [`catch_ctrlc`]. -/// -/// For how the `Ctrl-C` framework works, please refer to [documentation](catch_ctrlc) -/// -/// # Behavior -/// There are 2 main differences between this and the synchronous version: -/// 1. This does not spawn a dedicated thread. Instead, it's up to the async -/// runtime to execute the task. -/// 2. Even if the task does not check for the signal, the `Ctrl-C` signal -/// will cause the future to be dropped. In async terms, that means the future -/// will not be polled again if it yields back to the runtime (i.e. it -/// will be aborted) -/// -/// In the example below, even we never check the signal, the warning -/// will not print if `Ctrl-C` was pressed in time. -/// ```rust,ignore -/// # use pistonite_cu as cu; -/// use std::time::Duration; -/// -/// match cu::cli::co_catch_ctrlc(async |_| { -/// for _ in 0..10 { -/// cu::print!("please press Ctrl-C"); -/// cu::co::sleep(Duration::from_secs(2)).await; -/// } -/// cu::warn!("did not abort!"); -/// cu::Ok(42) -/// }).await { -/// Ok(None) => cu::info!("was aborted!"), -/// Ok(Some(n)) => cu::info!("was finished: {n}"), -/// Err(e) => cu::error!("error: {e:?}"), -/// } -/// ``` -/// -/// # Fallback -/// If the `Ctrl-C` framework failed to initialize, this may fallback to simply `.await`-ing -/// the task, which will make it not respond to `Ctrl-C` signals -/// -#[cfg(feature = "coroutine")] -pub async fn co_catch_ctrlc(f: F) -> cu::Result> -where - TFuture: Future> + Send + 'static, -F: FnOnce(CtrlcSignal) -> TFuture + Send + 'static, - T: Send + 'static -{ - let signal = CtrlcSignal::new(crate::next_atomic_usize()); + /// Execute the task + pub fn execute(self, f: F) -> cu::Result> + where + F: FnOnce(CtrlcSignal) -> cu::Result, { - let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { - cu::trace!("failed to register new ctrl-c frame, will simply await"); - return f(signal).await.map(Some); + let signal = CtrlcSignal::new(self.abort_threshold); + let Some(ctrlc_frame_scope) = CtrlcFrame::push_scope(signal.clone(), self.on_signal) else { + return f(signal).map(Some); }; - signal_stack.push(signal.clone()); + if let Err(e) = &*INIT_ONCE { + cu::bail!("{e}"); + } + let result = f(signal.clone()); + if signal.should_abort() { + return Ok(None); + } + drop(ctrlc_frame_scope); // suppress unused warning + result.map(Some) } - let _drop_scope = CtrlcScope(signal.id); - let handle = { - let signal = signal.clone(); - cu::co::spawn(async move { - // after the thread has spawned, check if it's already cancelled - if signal.signaled() { - return None; - } - Some(f(signal).await) - }) - }; - if let Err(e) = &*INIT_ONCE { - cu::bail!("{e}"); - } - let sleep_future = - async { - loop { - tokio::time::sleep(Duration::from_millis(200)).await; - if signal.signaled() { - break; - } - } + #[cfg(feature = "coroutine")] + pub async fn co_execute(self, f: F) -> cu::Result> + where + TFuture: Future>, + F: FnOnce(CtrlcSignal) -> TFuture, + { + let signal = CtrlcSignal::new(self.abort_threshold); + let Some(ctrlc_frame_scope) = CtrlcFrame::push_scope(signal.clone(), self.on_signal) else { + return f(signal).await.map(Some); }; - tokio::select! { - result = handle.co_join() => { - if signal.signaled() { - return Ok(None); - } - match result { - Err(e) => { - cu::bail!("failed to join ctrl-c frame: {e}"); - } - Ok(None) => Ok(None), - Ok(Some(v)) => Ok(Some(v?)), - } + if let Err(e) = &*INIT_ONCE { + cu::bail!("{e}"); } - _ = sleep_future => { - Ok(None) + let result = f(signal.clone()).await; + if signal.should_abort() { + return Ok(None); } + drop(ctrlc_frame_scope); // suppress unused warning + result.map(Some) } } +type OnSignalFn = Box; +struct CtrlcFrame { + id: usize, + signal: CtrlcSignal, + on_signal: Option, +} +struct CtrlcScope(usize); +/// Signal passed into a task executing inside a `Ctrl-C` action frame, +/// for it to check if `Ctrl-C` has been signaled #[derive(Clone)] pub struct CtrlcSignal { - id: usize, - signaled_times: Arc + signaled_times: Arc, + abort_threshold: u8, +} +impl CtrlcFrame { + pub fn push_scope(signal: CtrlcSignal, on_signal: Option) -> Option { + let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() else { + cu::trace!("failed to register new ctrl-c frame"); + return None; + }; + let id = crate::next_atomic_usize(); + signal_stack.push(Self { + id, + signal, + on_signal, + }); + Some(CtrlcScope(id)) + } +} +impl Drop for CtrlcScope { + fn drop(&mut self) { + if let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() { + signal_stack.retain(|x| x.id != self.0); + } + } } + impl CtrlcSignal { - fn new(id: usize) -> Self { - Self { id, signaled_times: Arc::new(AtomicU8::new(0)) } + fn new(abort_threshold: u8) -> Self { + Self { + signaled_times: Arc::new(AtomicU8::new(0)), + abort_threshold, + } } /// Return an `Err` if `Ctrl-C` has been signaled pub fn check(&self) -> cu::Result<()> { - if self.signaled() { + if self.should_abort() { cu::bail!("interrupted") } Ok(()) } - /// Return `true` if `Ctrl-C` has been signaled + /// Return `true` if `Ctrl-C` has been signaled at least + /// the same number of times as the abort_threshold + pub fn should_abort(&self) -> bool { + self.signaled_times() >= self.abort_threshold + } + /// Return `true` if `Ctrl-C` has been signaled at least once pub fn signaled(&self) -> bool { - self.signaled_times.load(Ordering::Acquire) > 0 + self.signaled_times() > 0 } /// Get the number of times `Ctrl-C` has been signaled pub fn signaled_times(&self) -> u8 { @@ -317,14 +334,6 @@ impl CtrlcSignal { /// Programmatically trigger the signal. Note that this does not /// send actual signal or keyboard events pub fn signal(&self) { - self.signaled_times.fetch_add(1, Ordering::AcqRel); - } -} -struct CtrlcScope(usize); -impl Drop for CtrlcScope { - fn drop(&mut self) { - if let Ok(mut signal_stack) = CTRLC_SIGNAL_STACK.lock() { - signal_stack.retain(|x| x.id != self.0); - } + self.signaled_times.fetch_add(1, Ordering::SeqCst); } } diff --git a/packages/copper/src/cli/mod.rs b/packages/copper/src/cli/mod.rs index 14949a7..9303d19 100644 --- a/packages/copper/src/cli/mod.rs +++ b/packages/copper/src/cli/mod.rs @@ -160,11 +160,9 @@ mod password; pub use password::password_chars_legal; mod ctrlc; -pub use ctrlc::{catch_ctrlc, CtrlcSignal}; #[cfg(feature = "cli")] pub use ctrlc::add_global_ctrlc_handler; -#[cfg(feature = "coroutine")] -pub use ctrlc::co_catch_ctrlc; +pub use ctrlc::{CtrlcBuilder, CtrlcSignal, ctrlc_frame}; /// Formatting utils pub(crate) mod fmt; diff --git a/packages/copper/src/cli/progress/builder.rs b/packages/copper/src/cli/progress/builder.rs index f7fa0d6..8fd1610 100644 --- a/packages/copper/src/cli/progress/builder.rs +++ b/packages/copper/src/cli/progress/builder.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use crate::cli::progress::{Estimater, ProgressBar, State, StateImmut}; @@ -209,4 +208,3 @@ impl ProgressBarBuilder { ProgressBar::spawn(state_immut, state, self.parent) } } - diff --git a/packages/copper/src/co/mod.rs b/packages/copper/src/co/mod.rs index 2de45ba..021ddac 100644 --- a/packages/copper/src/co/mod.rs +++ b/packages/copper/src/co/mod.rs @@ -72,7 +72,7 @@ //! ``` // re-exports -pub use tokio::{join, select, try_join, time::sleep}; +pub use tokio::{join, select, time::sleep, try_join}; mod runtime; #[cfg(not(feature = "coroutine-heavy"))] diff --git a/packages/copper/src/errhand.rs b/packages/copper/src/errhand.rs index 8734bf3..9845681 100644 --- a/packages/copper/src/errhand.rs +++ b/packages/copper/src/errhand.rs @@ -45,9 +45,10 @@ pub use anyhow::{Context, Error, Ok, Result, anyhow as fmterr, bail}; /// - [`cu::unimplemented!`](macro@crate::unimplemented) /// and [`cu::unreachable!`](macro@crate::unreachable) /// that are similar to the std macros, but instead of `panic!`, they will `bail!` -/// - [`cu::ensure`](macro@crate::ensure) is unlike `anyhow::ensure`, that +/// - [`cu::ensure!`](macro@crate::ensure) is unlike `anyhow::ensure`, that /// it evaluates to a `Result<()>` instead of generates a return. /// It also does not automatically generate debug information. +/// - [`cu::some!`] checks an `Option` and returns `Ok(None)` if the option is `None`. /// /// Here are other `anyhow` re-exports that are less commonly used /// - `anyhow::anyhow` is `cu::fmterr` @@ -220,6 +221,28 @@ macro_rules! ensure { }}; } +/// Check if an expression is `Some` +/// +/// This is a convienence macro to achieve a similar effect +/// of `?` on an option, in a function that returns `Result>` +/// +/// Effectively expands to +/// ```text +/// match { +/// Some(x) => x, +/// None => return Ok(None), +/// } +/// ``` +#[macro_export] +macro_rules! some { + ($result:expr) => { + match $result { + Some(x) => x, + None => return Ok(None), + } + }; +} + /// Invoke a print macro, then panic with the same message /// /// # Example diff --git a/packages/copper/src/lib.rs b/packages/copper/src/lib.rs index 4eb3e5c..f018892 100644 --- a/packages/copper/src/lib.rs +++ b/packages/copper/src/lib.rs @@ -62,7 +62,7 @@ //! - [Logging](mod@crate::lv) (via [`log`](https://docs.rs/log)) //! - [Printing and Command Line Interface](mod@crate::cli) (CLI arg parsing via //! [`clap`](https://docs.rs/clap)) -//! - [Handling Ctrl-C](fn@crate::cli::catch_ctrlc) +//! - [Handling Ctrl-C](fn@crate::cli::ctrlc_frame) //! - [Progress Bars](fn@crate::progress) //! - [Prompting](macro@crate::prompt) //! - [Coroutines (Async)](mod@crate::co) (via [`tokio`](https://docs.rs/tokio)) From bdb7ba437e13dd1c3da9af44461928236f465857 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 17 Jan 2026 13:56:03 -0800 Subject: [PATCH 4/5] fix doc command --- Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index e3102f2..0a2d384 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,7 +52,7 @@ tasks: doc: cmds: # note: require nightly toolchain in CI - - cargo doc --no-deps --features full,nightly -p pistonite_cu,pistonite_cu_proc_macros,pistonite_pm + - cargo doc --no-deps --features full,nightly -p pistonite-cu -p pistonite-cu-proc-macros -p pistonite-pm - cmd: > echo "Generating index redirect"; echo "" > target/doc/index.html; From e4453a7cfa2c695410d91fcb11437b8fee771292 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 17 Jan 2026 13:57:28 -0800 Subject: [PATCH 5/5] stop printing time in terminal tests --- packages/terminal-tests/examples/print_levels.rs | 1 + packages/terminal-tests/examples/prompt.rs | 1 + packages/terminal-tests/output/print_levels-2.txt | 1 - packages/terminal-tests/output/print_levels-3.txt | 1 - packages/terminal-tests/output/print_levels-4.txt | 3 +-- packages/terminal-tests/output/print_levels-7.txt | 1 - packages/terminal-tests/output/print_levels-8.txt | 1 - packages/terminal-tests/output/print_levels-9.txt | 3 +-- packages/terminal-tests/output/prompt-0.txt | 1 - packages/terminal-tests/output/prompt-1.txt | 1 - packages/terminal-tests/output/prompt-2.txt | 1 - packages/terminal-tests/output/prompt-3.txt | 1 - packages/terminal-tests/output/prompt-4.txt | 1 - packages/terminal-tests/output/prompt-5.txt | 1 - packages/terminal-tests/output/prompt-6.txt | 1 - 15 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/terminal-tests/examples/print_levels.rs b/packages/terminal-tests/examples/print_levels.rs index e0da13a..8bf8112 100644 --- a/packages/terminal-tests/examples/print_levels.rs +++ b/packages/terminal-tests/examples/print_levels.rs @@ -11,6 +11,7 @@ #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { + cu::lv::disable_print_time(); cu::info!( "this is an info messagenmultilineaa 你好 sldkfjals🤖kdjflkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdfkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldjflajsdlkfjlaskjdfklajsdf" ); diff --git a/packages/terminal-tests/examples/prompt.rs b/packages/terminal-tests/examples/prompt.rs index 933ea78..01074b9 100644 --- a/packages/terminal-tests/examples/prompt.rs +++ b/packages/terminal-tests/examples/prompt.rs @@ -8,6 +8,7 @@ #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { + cu::lv::disable_print_time(); cu::hint!("testing prompts"); if !cu::yesno!("continue?")? { cu::warn!("you chose to not continue!"); diff --git a/packages/terminal-tests/output/print_levels-2.txt b/packages/terminal-tests/output/print_levels-2.txt index 10cd55a..5e2cea5 100644 --- a/packages/terminal-tests/output/print_levels-2.txt +++ b/packages/terminal-tests/output/print_levels-2.txt @@ -9,7 +9,6 @@ E] this is error message^LF | ^LF :: today's weather is good^LF H] today's weather is ok^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/print_levels-3.txt b/packages/terminal-tests/output/print_levels-3.txt index 1fb088a..e6de77c 100644 --- a/packages/terminal-tests/output/print_levels-3.txt +++ b/packages/terminal-tests/output/print_levels-3.txt @@ -12,7 +12,6 @@ D] this is debug message^LF | ^LF :: today's weather is good^LF H] today's weather is ok^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/print_levels-4.txt b/packages/terminal-tests/output/print_levels-4.txt index 781fbcb..51bcd6b 100644 --- a/packages/terminal-tests/output/print_levels-4.txt +++ b/packages/terminal-tests/output/print_levels-4.txt @@ -10,12 +10,11 @@ E] this is error message^LF D] this is debug message^LF | 2^LF | ^LF -*] [print_levels print_levels.rs:20] this is trace message^LF +*] [print_levels print_levels.rs:21] this is trace message^LF | ^LF | 2^LF :: today's weather is good^LF H] today's weather is ok^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/print_levels-7.txt b/packages/terminal-tests/output/print_levels-7.txt index c14f7a7..6f879c5 100644 --- a/packages/terminal-tests/output/print_levels-7.txt +++ b/packages/terminal-tests/output/print_levels-7.txt @@ -9,7 +9,6 @@ STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> \x1B[90m | \x1B[91m^LF \x1B[90m::\x1B[0m today's weather is good^LF \x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF -\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/print_levels-8.txt b/packages/terminal-tests/output/print_levels-8.txt index 0b440f8..67715bd 100644 --- a/packages/terminal-tests/output/print_levels-8.txt +++ b/packages/terminal-tests/output/print_levels-8.txt @@ -12,7 +12,6 @@ STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> \x1B[90m | \x1B[96m^LF \x1B[90m::\x1B[0m today's weather is good^LF \x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF -\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/print_levels-9.txt b/packages/terminal-tests/output/print_levels-9.txt index 27414e4..bce5eed 100644 --- a/packages/terminal-tests/output/print_levels-9.txt +++ b/packages/terminal-tests/output/print_levels-9.txt @@ -10,12 +10,11 @@ STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> \x1B[90mD]\x1B[96m this is debug message^LF \x1B[90m | \x1B[96m2^LF \x1B[90m | \x1B[96m^LF -\x1B[95m*]\x1B[95m [print_levels print_levels.rs:20] this is trace message^LF +\x1B[95m*]\x1B[95m [print_levels print_levels.rs:21] this is trace message^LF \x1B[90m | \x1B[95m^LF \x1B[90m | \x1B[95m2^LF \x1B[90m::\x1B[0m today's weather is good^LF \x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF -\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-0.txt b/packages/terminal-tests/output/prompt-0.txt index f9a5714..ee7e0cc 100644 --- a/packages/terminal-tests/output/prompt-0.txt +++ b/packages/terminal-tests/output/prompt-0.txt @@ -2,7 +2,6 @@ $ -y --non-interactive STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> H] testing prompts^LF E] fatal: prompt not allowed with --non-interactive^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-1.txt b/packages/terminal-tests/output/prompt-1.txt index 4789ae8..940e5f2 100644 --- a/packages/terminal-tests/output/prompt-1.txt +++ b/packages/terminal-tests/output/prompt-1.txt @@ -2,7 +2,6 @@ $ --non-interactive STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> H] testing prompts^LF E] fatal: prompt not allowed with --non-interactive^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-2.txt b/packages/terminal-tests/output/prompt-2.txt index 42bc881..130ade6 100644 --- a/packages/terminal-tests/output/prompt-2.txt +++ b/packages/terminal-tests/output/prompt-2.txt @@ -8,7 +8,6 @@ H] testing prompts^LF ^CR \x1B[KI] you answered: rust^LF I] the answer is correct^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-3.txt b/packages/terminal-tests/output/prompt-3.txt index 212a297..8f71c4b 100644 --- a/packages/terminal-tests/output/prompt-3.txt +++ b/packages/terminal-tests/output/prompt-3.txt @@ -13,7 +13,6 @@ H] testing prompts^LF ^CR \x1B[KI] you answered: rust^LF I] the answer is correct^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-4.txt b/packages/terminal-tests/output/prompt-4.txt index c2ed302..46d09eb 100644 --- a/packages/terminal-tests/output/prompt-4.txt +++ b/packages/terminal-tests/output/prompt-4.txt @@ -13,7 +13,6 @@ H] testing prompts^LF ^CR \x1B[KI] you answered: json^LF E] fatal: the answer is incorrect^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-5.txt b/packages/terminal-tests/output/prompt-5.txt index 45463fc..4c4a597 100644 --- a/packages/terminal-tests/output/prompt-5.txt +++ b/packages/terminal-tests/output/prompt-5.txt @@ -7,7 +7,6 @@ H] testing prompts^LF \x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF ^CR \x1B[KW] you chose to not continue!^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> diff --git a/packages/terminal-tests/output/prompt-6.txt b/packages/terminal-tests/output/prompt-6.txt index 039c6ce..7146820 100644 --- a/packages/terminal-tests/output/prompt-6.txt +++ b/packages/terminal-tests/output/prompt-6.txt @@ -13,7 +13,6 @@ H] testing prompts^LF \x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF ^CR \x1B[KW] you chose to not continue!^LF -I] finished in 0.00s^LF ^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>