From b14fa93b276d742f8f755b0c08d0400197bca62a Mon Sep 17 00:00:00 2001 From: sanaz <35961250+staheri14@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:32:08 -0700 Subject: [PATCH 1/3] eval: port context extras (deadline/timeout, err, with_cancel) onto IrrevocableContext Additive integration of the deadline-related features from sanaz/context-with-tokio (ThrowableContext) onto main's IrrevocableContext: with_timeout/deadline/is_deadline_exceeded, deadline arm in run(), err(), and with_cancel(). The Go-style value bag from that branch was intentionally excluded (no consumer; deliberately dropped by PR #65's simplification). Candidate for evaluation; deadline is not yet wired into any consumer. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/context/context_test.rs | 46 ++++++++++++++++++ src/core/context/mod.rs | 83 +++++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/src/core/context/context_test.rs b/src/core/context/context_test.rs index 7e8d079..e8685db 100644 --- a/src/core/context/context_test.rs +++ b/src/core/context/context_test.rs @@ -149,4 +149,50 @@ mod tests { } } } + + // --- ported "extras" feature tests (candidate for main integration) --- + + /// a context created with a timeout fails a longer-running operation with a deadline error + #[tokio::test] + async fn test_run_respects_deadline() { + let ctx = + IrrevocableContext::with_timeout(&span_fixture(), "timeout_ctx", Duration::from_millis(10)); + + let result = ctx + .run(async { + sleep(Duration::from_millis(200)).await; + Ok::<(), anyhow::Error>(()) + }) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("deadline exceeded")); + } + + /// is_deadline_exceeded reflects the deadline crossing + #[tokio::test] + async fn test_is_deadline_exceeded() { + let ctx = + IrrevocableContext::with_timeout(&span_fixture(), "timeout_ctx", Duration::from_millis(5)); + assert!(!ctx.is_deadline_exceeded()); + assert!(ctx.deadline().is_some()); + sleep(Duration::from_millis(20)).await; + assert!(ctx.is_deadline_exceeded()); + assert!(ctx.err().is_some()); + } + + /// with_cancel returns a child plus a closure that cancels it + #[tokio::test] + async fn test_with_cancel_closure() { + let root = IrrevocableContext::new(&span_fixture(), "cancel_root"); + let (child, cancel) = root.with_cancel("cancel_child"); + + assert!(!child.is_cancelled()); + cancel(); + + let child_clone = child.clone(); + wait_until(move || child_clone.is_cancelled(), Duration::from_millis(100)) + .await + .expect("child should be cancelled after invoking cancel closure"); + } } diff --git a/src/core/context/mod.rs b/src/core/context/mod.rs index a0a5106..73f360c 100644 --- a/src/core/context/mod.rs +++ b/src/core/context/mod.rs @@ -1,18 +1,20 @@ //! Cancelable context with irrecoverable error propagation //! -//! This module provides a simplified context implementation focused on: +//! This module provides a context implementation focused on: //! - Cancellation support via tokio's CancellationToken -//! - Parent-child context hierarchies +//! - Parent-child context hierarchies //! - Irrecoverable error propagation that terminates the application +//! - Optional deadline/timeout support //! -//! Unlike the full Go context API, this implementation focuses only on the core -//! functionality needed: cancellation and error propagation. +//! The deadline feature is additive: a context created with `new` behaves +//! exactly like the previous minimal API (no deadline). #[cfg(test)] mod context_test; use anyhow::Result; use std::sync::Arc; +use tokio::time::Instant; use tokio_util::sync::CancellationToken; use tracing::Span; @@ -26,6 +28,8 @@ pub struct IrrevocableContext { struct ContextInner { token: CancellationToken, + /// Absolute deadline after which the context is considered expired, if any. + deadline: Option, parent: Option, span: Span, } @@ -38,18 +42,34 @@ impl IrrevocableContext { Self { inner: Arc::new(ContextInner { token: CancellationToken::new(), + deadline: None, parent: None, span, }), } } - /// Create a child context that inherits cancellation from the parent + /// Create a new root context that expires after `timeout` elapses. + pub fn with_timeout(parent_span: &Span, tag: &str, timeout: std::time::Duration) -> Self { + let span = tracing::span!(parent: parent_span, tracing::Level::TRACE, "irrevocable_context_timeout", tag = tag); + + Self { + inner: Arc::new(ContextInner { + token: CancellationToken::new(), + deadline: Some(Instant::now() + timeout), + parent: None, + span, + }), + } + } + + /// Create a child context that inherits cancellation and deadline from the parent. pub fn child(&self, tag: &str) -> Self { let span = tracing::span!(parent: &self.inner.span, tracing::Level::TRACE, "irrevocable_context_child", tag = tag); Self { inner: Arc::new(ContextInner { token: self.inner.token.child_token(), + deadline: self.inner.deadline, parent: Some(self.clone()), span, }), @@ -74,19 +94,49 @@ impl IrrevocableContext { self.inner.token.cancelled().await; } - /// Run an operation with cancellation support - /// If the context is cancelled before the operation completes, it returns an error. - /// otherwise, it returns the operation's result. + /// Returns the absolute deadline for this context, if one was set. + pub fn deadline(&self) -> Option { + self.inner.deadline + } + + /// Returns true if the context has a deadline that has already passed. + pub fn is_deadline_exceeded(&self) -> bool { + self.inner.deadline.is_some_and(|d| Instant::now() >= d) + } + + /// Returns an error describing why the context is unusable (cancelled or past deadline), if so. + pub fn err(&self) -> Option { + if self.is_cancelled() { + Some(anyhow::anyhow!("context cancelled")) + } else if self.is_deadline_exceeded() { + Some(anyhow::anyhow!("context deadline exceeded")) + } else { + None + } + } + + /// Run an operation with cancellation and deadline support. + /// Returns an error if the context is cancelled or its deadline elapses before the operation completes. pub async fn run(&self, future: F) -> Result where F: std::future::Future>, { let _enter = self.inner.span.enter(); - tokio::select! { - result = future => result, - _ = self.cancelled() => { - Err(anyhow::anyhow!("context cancelled")) + match self.inner.deadline { + Some(deadline) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + result = future => result, + _ = tokio::time::sleep(remaining) => Err(anyhow::anyhow!("context deadline exceeded")), + _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + } + } + None => { + tokio::select! { + result = future => result, + _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + } } } } @@ -120,6 +170,14 @@ impl IrrevocableContext { Err(err) => self.throw_irrecoverable(err), } } + + /// Returns a cancellable child context together with a function that cancels it. + /// Mirrors Go's `context.WithCancel`. + pub fn with_cancel(&self, tag: &str) -> (Self, impl Fn()) { + let child = self.child(tag); + let token = child.inner.token.clone(); + (child, move || token.cancel()) + } } // Custom Debug implementation for better visibility into the context state @@ -127,6 +185,7 @@ impl std::fmt::Debug for IrrevocableContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("IrrevocableContext") .field("is_cancelled", &self.is_cancelled()) + .field("deadline", &self.inner.deadline) .field("has_parent", &self.inner.parent.is_some()) .finish() } From 6e0f90a7949efa65d3d924af062cd835053660e5 Mon Sep 17 00:00:00 2001 From: sanaz <35961250+staheri14@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:03:44 -0700 Subject: [PATCH 2/3] address review: short-circuit run() on dead context; widen with_cancel bound; add tests Addresses PR #82 review points 1, 7, 6, 4: - run() now short-circuits via err() before the select!, so an already-cancelled or already-expired context can't return Ok by racing an immediately-ready future (1) - test_run_short_circuits_when_deadline_already_passed covers that case (7) - test_run_cancellation_beats_deadline exercises the Some(deadline) arm's cancelled() branch via concurrent cancellation (6) - with_cancel returns impl Fn() + Send + Sync + Clone so the trigger can cross threads/tasks and be stored (4) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/context/context_test.rs | 45 ++++++++++++++++++++++++++++++++ src/core/context/mod.rs | 12 ++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/core/context/context_test.rs b/src/core/context/context_test.rs index e8685db..40a01a2 100644 --- a/src/core/context/context_test.rs +++ b/src/core/context/context_test.rs @@ -195,4 +195,49 @@ mod tests { .await .expect("child should be cancelled after invoking cancel closure"); } + + /// run() on an already-expired context returns the deadline error even when the + /// operation itself is immediately ready (guards the short-circuit / select! race) + #[tokio::test] + async fn test_run_short_circuits_when_deadline_already_passed() { + let ctx = + IrrevocableContext::with_timeout(&span_fixture(), "expired_ctx", Duration::from_millis(5)); + // let the deadline lapse before we ever call run() + sleep(Duration::from_millis(20)).await; + assert!(ctx.is_deadline_exceeded()); + + // an immediately-ready future must NOT slip through on an expired context + let result = ctx.run(async { Ok::(42) }).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("deadline exceeded")); + } + + /// cancellation that fires *while* run() is in-flight wins the race inside the + /// Some(deadline) arm — exercises select!'s `cancelled()` branch (not the short-circuit) + #[tokio::test] + async fn test_run_cancellation_beats_deadline() { + // generous deadline so the timer never fires; the context is live when run() starts + let ctx = IrrevocableContext::with_timeout( + &span_fixture(), + "cancel_vs_deadline", + Duration::from_secs(60), + ); + let canceller = ctx.clone(); + + // run a long operation, and concurrently cancel shortly after it begins + let (result, ()) = tokio::join!( + ctx.run(async { + sleep(Duration::from_millis(200)).await; + Ok::<(), anyhow::Error>(()) + }), + async move { + sleep(Duration::from_millis(10)).await; + canceller.cancel(); + } + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("context cancelled")); + } } diff --git a/src/core/context/mod.rs b/src/core/context/mod.rs index 73f360c..382a585 100644 --- a/src/core/context/mod.rs +++ b/src/core/context/mod.rs @@ -123,6 +123,13 @@ impl IrrevocableContext { { let _enter = self.inner.span.enter(); + // Short-circuit if the context is already unusable, so an already-cancelled + // or already-expired context never races an immediately-ready future (which + // `select!` would otherwise resolve pseudo-randomly). + if let Some(err) = self.err() { + return Err(err); + } + match self.inner.deadline { Some(deadline) => { let remaining = deadline.saturating_duration_since(Instant::now()); @@ -173,7 +180,10 @@ impl IrrevocableContext { /// Returns a cancellable child context together with a function that cancels it. /// Mirrors Go's `context.WithCancel`. - pub fn with_cancel(&self, tag: &str) -> (Self, impl Fn()) { + /// + /// The returned closure is `Send + Sync + Clone`, so it can be moved across + /// threads/tasks or stored — the intended way to hand out cancel authority. + pub fn with_cancel(&self, tag: &str) -> (Self, impl Fn() + Send + Sync + Clone) { let child = self.child(tag); let token = child.inner.token.clone(); (child, move || token.cancel()) From 27484625e9ab84b144f595303ad606273b2a4955 Mon Sep 17 00:00:00 2001 From: sanaz <35961250+staheri14@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:10:16 -0700 Subject: [PATCH 3/3] context: instrument run() span instead of holding enter() guard across await --- src/core/context/mod.rs | 49 +++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/core/context/mod.rs b/src/core/context/mod.rs index 382a585..67ced7f 100644 --- a/src/core/context/mod.rs +++ b/src/core/context/mod.rs @@ -16,7 +16,7 @@ use anyhow::Result; use std::sync::Arc; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use tracing::Span; +use tracing::{Instrument, Span}; /// A cancelable context that supports parent-child hierarchies and irrecoverable error propagation. /// @@ -121,31 +121,38 @@ impl IrrevocableContext { where F: std::future::Future>, { - let _enter = self.inner.span.enter(); - - // Short-circuit if the context is already unusable, so an already-cancelled - // or already-expired context never races an immediately-ready future (which - // `select!` would otherwise resolve pseudo-randomly). - if let Some(err) = self.err() { - return Err(err); - } + // Attach the context span via `.instrument()` rather than holding an + // `enter()` guard across the `.await`: the guard is `!Send` and would + // stay entered while the future is suspended, leaking the span onto + // whatever task resumes it. `.instrument()` enters the span only while + // this future is actively polled. + async move { + // Short-circuit if the context is already unusable, so an already-cancelled + // or already-expired context never races an immediately-ready future (which + // `select!` would otherwise resolve pseudo-randomly). + if let Some(err) = self.err() { + return Err(err); + } - match self.inner.deadline { - Some(deadline) => { - let remaining = deadline.saturating_duration_since(Instant::now()); - tokio::select! { - result = future => result, - _ = tokio::time::sleep(remaining) => Err(anyhow::anyhow!("context deadline exceeded")), - _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + match self.inner.deadline { + Some(deadline) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + result = future => result, + _ = tokio::time::sleep(remaining) => Err(anyhow::anyhow!("context deadline exceeded")), + _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + } } - } - None => { - tokio::select! { - result = future => result, - _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + None => { + tokio::select! { + result = future => result, + _ = self.cancelled() => Err(anyhow::anyhow!("context cancelled")), + } } } } + .instrument(self.inner.span.clone()) + .await } /// Propagate an irrecoverable error up the context chain.