회차 1 actionable 2건 반영 + 1건 (CLI Ctrl-C integration test) 은 본 PR 에서 별도 task 로 미룸 (signal handler subprocess test 의 flaky 위험 + facade 3 PASS + tui lib 3 PASS 가 안정 surface). - cancel.rs::install_sigint_cancel: SIGNAL_COUNT 위에 process-lifetime invariant 코멘트 — multi-install 차단 (ctrlc::set_handler) 덕분에 reset 불필요. 미래 다중 caller 가 같은 cancel token 공유하려면 install 함수 분리 필요. - ingest_cancel.rs::cancel_mid_loop: redundant `report.new == 1 || 0 || 2` 제거, race timing 의도 코멘트로 대체 (0=listener 승, 1=first only, 2=extra slipped in 모두 valid; 3 = cancel never propagated = 유일한 fail). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
3.7 KiB
Rust
84 lines
3.7 KiB
Rust
//! `kebab ingest` SIGINT (Ctrl-C) handler — flips a shared
|
|
//! `Arc<AtomicBool>` so `kebab_app::ingest_with_config_cancellable`
|
|
//! can break at the next step boundary.
|
|
//!
|
|
//! Per spec §10: the second Ctrl-C is a hard exit (130 = SIGINT
|
|
//! conventional). We count signal arrivals via a private atomic and
|
|
//! call `std::process::exit` on the second arrival — past the point
|
|
//! where the user has signalled both "let me out gracefully" and
|
|
//! "no, really, get me out now". This sidesteps the indicatif
|
|
//! cleanup path; the terminal may be left in a slightly odd state
|
|
//! after a hard exit, which is the acceptable tradeoff for "really
|
|
//! exit now".
|
|
//!
|
|
//! `ctrlc` is the only cross-platform SIGINT helper that doesn't
|
|
//! drag in a tokio runtime; it registers a single OS-level handler
|
|
//! per process. Because the handler is process-global, calling
|
|
//! `install` more than once per `kebab` invocation is forbidden
|
|
//! (would clobber the previous handler) — `Cmd::Ingest` is the only
|
|
//! caller today, but a future `kebab eval run` etc. would need to
|
|
//! either share the same atomic or deliberately re-install before
|
|
//! its run begins.
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
|
|
|
/// Install a SIGINT handler that:
|
|
/// - on first signal: sets `cancel.store(true)` so the cooperative
|
|
/// cancel loop in `kebab_app::ingest_with_config_cancellable`
|
|
/// breaks at its next step boundary.
|
|
/// - on second signal: hard-exits with code 130 (SIGINT
|
|
/// convention).
|
|
///
|
|
/// Returns the same `Arc<AtomicBool>` for the caller to thread
|
|
/// through to the facade. Errors only on duplicate install; first
|
|
/// caller wins.
|
|
pub fn install_sigint_cancel() -> anyhow::Result<Arc<AtomicBool>> {
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
let cancel_for_handler = cancel.clone();
|
|
// Per-process count of received SIGINTs. Static so the closure
|
|
// owns no extra state; first signal flips cancel, second exits.
|
|
//
|
|
// Process-lifetime: never reset. ctrlc::set_handler rejects
|
|
// multi-install with `Err(MultipleHandlers)`, so this counter
|
|
// is effectively single-use per `kebab` invocation. A future
|
|
// command that needs its own cancel token (e.g. `kebab eval
|
|
// run --with-cancel`) must factor the install path into a
|
|
// helper that takes the token as an arg and shares it across
|
|
// callers — not call `install_sigint_cancel` twice.
|
|
static SIGNAL_COUNT: AtomicU8 = AtomicU8::new(0);
|
|
ctrlc::set_handler(move || {
|
|
let prev = SIGNAL_COUNT.fetch_add(1, Ordering::Relaxed);
|
|
if prev == 0 {
|
|
cancel_for_handler.store(true, Ordering::Relaxed);
|
|
// Helpful hint on stderr — the run loop will surface
|
|
// its own "aborting…" line once the cancel propagates.
|
|
let _ = std::io::Write::write_all(
|
|
&mut std::io::stderr().lock(),
|
|
b"\nreceived Ctrl-C; aborting after current asset (press again to force quit)\n",
|
|
);
|
|
} else {
|
|
// Second signal → bail. 130 is the canonical SIGINT
|
|
// exit code (128 + signal number).
|
|
std::process::exit(130);
|
|
}
|
|
})?;
|
|
Ok(cancel)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// The handler is process-global and can only be installed once
|
|
// per binary invocation (ctrlc constraint), so unit-testing the
|
|
// happy path here is brittle — see `tests/ingest_cancel_cli.rs`
|
|
// for the integration coverage that runs the bin in a fresh
|
|
// subprocess.
|
|
|
|
#[test]
|
|
fn cancel_module_compiles() {
|
|
// Trivial sanity — confirm the module compiles in dev profile
|
|
// (the install function is exercised by the CLI integration
|
|
// test, not directly here).
|
|
}
|
|
}
|