Skip to content

Commit 328e2ad

Browse files
georgeh0claude
andauthored
fix: suppress noisy CancelledError log on Ctrl+C in live mode (#1805)
Add is_cancelled() to HostError trait so host-language error wrappers can indicate cancellation structurally. HostedPyErr implements it by checking asyncio.CancelledError. The live component error handler now treats cancellation errors as normal shutdown instead of logging at ERROR level. This fixes the race where Python CancelledError propagates through process_live_fut before the cancellation token is cancelled, causing a noisy traceback on Ctrl+C. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30a960f commit 328e2ad

3 files changed

Lines changed: 38 additions & 4 deletions

File tree

rust/core/src/engine/live_component.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,13 @@ impl<Prof: EngineProfile> LiveComponentController<Prof> {
501501
match result {
502502
Ok(()) => state.ensure_mark_ready(),
503503
Err(e) => {
504-
if !state.is_ready() {
505-
// Error before mark_ready — drop the guard to signal error.
504+
// Check if this is a cancellation — either the token was
505+
// cancelled (normal shutdown) or the error itself is a
506+
// cancellation (e.g. Python CancelledError, which can race
507+
// ahead of token cancellation on KeyboardInterrupt).
508+
if token.is_cancelled() || e.is_cancelled() {
509+
state.ensure_mark_ready();
510+
} else if !state.is_ready() {
506511
state.resolve_ready_with_error(e);
507512
} else {
508513
error!("process_live failed after mark_ready: {e:?}");

rust/py_utils/src/error.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ impl std::error::Error for HostedPyErr {
6161
}
6262
}
6363

64+
impl cocoindex_utils::error::HostError for HostedPyErr {
65+
fn is_cancelled(&self) -> bool {
66+
Python::attach(|py| {
67+
let Ok(asyncio) = PyModule::import(py, "asyncio") else {
68+
return false;
69+
};
70+
let Ok(cancelled_cls) = asyncio.getattr("CancelledError") else {
71+
return false;
72+
};
73+
self.0.is_instance(py, &cancelled_cls)
74+
})
75+
}
76+
}
77+
6478
fn cerror_to_pyerr(err: CError) -> PyErr {
6579
match err.without_contexts() {
6680
CError::HostLang(host_err) => {

rust/utils/src/error.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ use std::{
1212
sync::{Arc, Mutex},
1313
};
1414

15-
pub trait HostError: Any + StdError + Send + Sync + 'static {}
16-
impl<T: Any + StdError + Send + Sync + 'static> HostError for T {}
15+
pub trait HostError: Any + StdError + Send + Sync + 'static {
16+
/// Whether this error represents a cancellation (e.g. Python `CancelledError`).
17+
/// Default: `false`. Override in host-language error wrappers.
18+
fn is_cancelled(&self) -> bool {
19+
false
20+
}
21+
}
1722

1823
pub enum Error {
1924
Context { msg: String, source: Box<SError> },
@@ -87,6 +92,15 @@ impl Error {
8792
}
8893
}
8994

95+
/// Check if this error represents a cancellation (e.g. Python `CancelledError`).
96+
pub fn is_cancelled(&self) -> bool {
97+
let inner = self.without_contexts();
98+
if let Error::HostLang(host_err) = inner {
99+
return host_err.is_cancelled();
100+
}
101+
false
102+
}
103+
90104
pub fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
91105
match self {
92106
Error::Context { source, .. } => Some(source.as_ref()),
@@ -470,6 +484,7 @@ mod tests {
470484
}
471485

472486
impl StdError for MockHostError {}
487+
impl HostError for MockHostError {}
473488

474489
#[test]
475490
fn test_client_error_creation() {

0 commit comments

Comments
 (0)