From f9a6610fa952954f91782b4ac10139ef60e0229d Mon Sep 17 00:00:00 2001 From: proboscis Date: Mon, 16 Mar 2026 22:52:57 +0900 Subject: [PATCH 1/4] Track last active chain on RunResult --- doeff/_types_internal.py | 4 +- packages/doeff-vm-core/src/vm.rs | 3 ++ packages/doeff-vm-core/src/vm/dispatch.rs | 3 +- packages/doeff-vm-core/src/vm/step.rs | 43 +++++++++------ packages/doeff-vm-core/src/vm/vm_trace.rs | 14 +++++ packages/doeff-vm/doeff_vm/__init__.pyi | 1 + packages/doeff-vm/src/pyvm.rs | 33 ++++++++++++ .../core/test_vm_proto_004_traceback_data.py | 52 ++++++++++++++++++- 8 files changed, 134 insertions(+), 19 deletions(-) diff --git a/doeff/_types_internal.py b/doeff/_types_internal.py index d6ff8fba..b1d4dcef 100644 --- a/doeff/_types_internal.py +++ b/doeff/_types_internal.py @@ -4,7 +4,6 @@ This module contains the foundational types with zero internal dependencies. """ - import json import traceback import warnings @@ -693,6 +692,9 @@ def error(self) -> BaseException: ... @property def traceback_data(self) -> Any | None: ... + @property + def last_active_chain(self) -> Any: ... + def is_ok(self) -> bool: ... def is_err(self) -> bool: ... diff --git a/packages/doeff-vm-core/src/vm.rs b/packages/doeff-vm-core/src/vm.rs index 0d59a0a7..b8229f22 100644 --- a/packages/doeff-vm-core/src/vm.rs +++ b/packages/doeff-vm-core/src/vm.rs @@ -277,6 +277,7 @@ pub struct VM { pub current_segment: Option, pub(crate) debug: DebugState, pub(crate) trace_state: TraceState, + pub(crate) last_active_chain: Vec, pub continuation_registry: HashMap, pub active_run_token: Option, } @@ -294,6 +295,7 @@ impl VM { current_segment: None, debug: DebugState::new(DebugConfig::default()), trace_state: TraceState::default(), + last_active_chain: Vec::new(), continuation_registry: HashMap::new(), active_run_token: None, } @@ -313,6 +315,7 @@ impl VM { let token = NEXT_RUN_TOKEN.fetch_add(1, Ordering::Relaxed); self.active_run_token = Some(token); self.trace_state.clear(); + self.last_active_chain.clear(); self.interceptor_state.clear_for_run(); self.run_handlers.clear(); token diff --git a/packages/doeff-vm-core/src/vm/dispatch.rs b/packages/doeff-vm-core/src/vm/dispatch.rs index 1dad32a5..e59084fc 100644 --- a/packages/doeff-vm-core/src/vm/dispatch.rs +++ b/packages/doeff-vm-core/src/vm/dispatch.rs @@ -1377,11 +1377,12 @@ impl VM { if let Some((_dispatch_id, original_exception, terminal)) = error_dispatch { if terminal { - let active_chain = self + let active_chain: Vec = self .assemble_active_chain(Some(&original_exception)) .into_iter() .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) .collect(); + self.set_last_active_chain(active_chain.clone()); let enriched_exception = match TraceState::enrich_original_exception_with_context( original_exception, value, diff --git a/packages/doeff-vm-core/src/vm/step.rs b/packages/doeff-vm-core/src/vm/step.rs index 9ef6fe3a..a5607a48 100644 --- a/packages/doeff-vm-core/src/vm/step.rs +++ b/packages/doeff-vm-core/src/vm/step.rs @@ -12,16 +12,6 @@ impl VM { StepEvent::Continue } - pub(super) fn throw_handler_protocol_error(&mut self, message: impl Into) -> StepEvent { - if self.current_segment.is_none() { - return StepEvent::Error(VMError::internal( - "throw_handler_protocol_error called without current segment", - )); - } - self.set_contextual_throw(PyException::handler_protocol_error(message)); - StepEvent::Continue - } - pub(super) fn contextual_throw_mode(&mut self, exception: PyException) -> Mode { self.mode_after_generror(GenErrorSite::VmRaisedUser, exception, false) } @@ -188,6 +178,7 @@ impl VM { self.finalize_active_dispatches_as_threw(&exc); let trace = self.assemble_traceback_entries(&exc); let active_chain = self.assemble_active_chain(Some(&exc)); + self.set_last_active_chain(active_chain.clone()); self.segments.reparent_children(seg_id, None); self.segments.free(seg_id); self.current_segment = None; @@ -440,11 +431,14 @@ impl VM { .contains_key(&continuation.cont_id) && !self.is_one_shot_consumed(continuation.cont_id) { - self.mark_one_shot_consumed(continuation.cont_id); - return self.throw_handler_protocol_error(format!( + let exception = PyException::handler_protocol_error(format!( "handler returned without consuming continuation {}; use Resume(k, v), Transfer(k, v), Discontinue(k, exn), or Pass()", continuation.cont_id.raw(), )); + self.mark_one_shot_consumed(continuation.cont_id); + self.emit_handler_threw_for_dispatch(dispatch_id, &exception); + self.current_seg_mut().mode = self.contextual_throw_mode(exception); + return StepEvent::Continue; } if let Err(err) = self @@ -500,11 +494,12 @@ impl VM { match mode { Mode::Deliver(value) => { if let Some(original) = k_origin.pending_error_context { - let active_chain = self + let active_chain: Vec = self .assemble_active_chain(Some(&original)) .into_iter() .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) .collect(); + self.set_last_active_chain(active_chain.clone()); self.current_seg_mut().mode = match TraceState::enrich_original_exception_with_context( original, @@ -527,11 +522,14 @@ impl VM { unreachable!("dispatch origin frame received HandleYield mode: {yielded:?}") } Mode::Return(value) => { - return self.throw_handler_protocol_error(format!( + let exception = PyException::handler_protocol_error(format!( "handler returned without consuming continuation before dispatch {} completed: {:?}", dispatch_id.raw(), value, - )) + )); + self.emit_handler_threw_for_dispatch(dispatch_id, &exception); + self.current_seg_mut().mode = self.contextual_throw_mode(exception); + StepEvent::Continue } } } @@ -2449,7 +2447,20 @@ impl VM { } if handler_kind == Some(HandlerKind::Python) { if let Some(exception) = Self::returned_control_primitive_exception(&value) { - self.set_contextual_throw(exception); + if let Some(dispatch_id) = + self.current_active_handler_dispatch_id().or_else(|| { + let dispatch_id = self.current_segment_dispatch_id_any()?; + if self.current_segment_is_active_handler_for_dispatch(dispatch_id) + { + Some(dispatch_id) + } else { + None + } + }) + { + self.emit_handler_threw_for_dispatch(dispatch_id, &exception); + } + self.current_seg_mut().mode = self.contextual_throw_mode(exception); return; } } diff --git a/packages/doeff-vm-core/src/vm/vm_trace.rs b/packages/doeff-vm-core/src/vm/vm_trace.rs index f6393188..84cd9e61 100644 --- a/packages/doeff-vm-core/src/vm/vm_trace.rs +++ b/packages/doeff-vm-core/src/vm/vm_trace.rs @@ -322,6 +322,8 @@ impl VM { return Mode::Throw(exception); } + self.capture_last_active_chain(Some(&exception)); + if exception.is_materialized_synthetic_vm_error() && !active_handler_supports_conversion { return Mode::Throw(exception); } @@ -430,6 +432,18 @@ impl VM { ) } + pub(crate) fn capture_last_active_chain(&mut self, exception: Option<&PyException>) { + self.last_active_chain = self.assemble_active_chain(exception); + } + + pub(crate) fn set_last_active_chain(&mut self, active_chain: Vec) { + self.last_active_chain = active_chain; + } + + pub fn last_active_chain(&self) -> &[ActiveChainEntry] { + &self.last_active_chain + } + fn should_attach_active_chain_for_dispatch(&self, dispatch_id: DispatchId) -> bool { let Some(origin) = self.dispatch_origin_for_dispatch_id(dispatch_id) else { return false; diff --git a/packages/doeff-vm/doeff_vm/__init__.pyi b/packages/doeff-vm/doeff_vm/__init__.pyi index b7e48b9b..d0c0586d 100644 --- a/packages/doeff-vm/doeff_vm/__init__.pyi +++ b/packages/doeff-vm/doeff_vm/__init__.pyi @@ -128,6 +128,7 @@ RunResultValue: TypeAlias = Ok[_T] | Err class RunResult(Generic[_T]): traceback_data: DoeffTracebackData | None + last_active_chain: Any @property def value(self) -> _T: ... @property diff --git a/packages/doeff-vm/src/pyvm.rs b/packages/doeff-vm/src/pyvm.rs index fbea1ee0..9c7c2233 100644 --- a/packages/doeff-vm/src/pyvm.rs +++ b/packages/doeff-vm/src/pyvm.rs @@ -469,10 +469,13 @@ impl PyVM { for entry in self.vm.rust_store.logs() { log_list.append(entry.to_pyobject(py)?)?; } + let last_active_chain = + self.build_last_active_chain(py, traceback_data.as_ref().map(|data| data.bind(py)))?; Ok(PyRunResult { result, traceback_data, + last_active_chain, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -580,6 +583,27 @@ impl PyVM { Ok(trace_list.into_any().unbind()) } + fn build_last_active_chain( + &self, + py: Python<'_>, + traceback_data: Option<&Bound<'_, PyDoeffTracebackData>>, + ) -> PyResult> { + if !self.vm.last_active_chain().is_empty() { + return Ok(Value::ActiveChain(self.vm.last_active_chain().to_vec()) + .to_pyobject(py)? + .unbind()); + } + + if let Some(traceback_data) = traceback_data { + let active_chain = traceback_data.borrow().active_chain.clone_ref(py); + if !active_chain.bind(py).is_none() { + return Ok(active_chain); + } + } + + Ok(PyList::empty(py).into_any().unbind()) + } + pub fn build_run_result( &self, py: Python<'_>, @@ -596,6 +620,7 @@ impl PyVM { Ok(PyRunResult { result: Ok(value.unbind()), traceback_data: None, + last_active_chain: self.build_last_active_chain(py, None)?, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -618,9 +643,11 @@ impl PyVM { log_list.append(entry.to_pyobject(py)?)?; } let exc = pyerr_to_exception(py, PyErr::from_value(error))?; + let last_active_chain = self.build_last_active_chain(py, traceback_data.as_ref())?; Ok(PyRunResult { result: Err(exc), traceback_data: traceback_data.map(Bound::unbind), + last_active_chain, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -2024,6 +2051,7 @@ pub struct PyRunResult { result: Result, PyException>, #[pyo3(get)] traceback_data: Option>, + last_active_chain: Py, raw_store: Py, log: Py, trace: Py, @@ -2144,6 +2172,11 @@ impl PyRunResult { self.raw_store.clone_ref(py).into_any() } + #[getter] + fn last_active_chain(&self, py: Python<'_>) -> Py { + self.last_active_chain.clone_ref(py) + } + #[getter] fn log(&self, py: Python<'_>) -> Py { self.log.clone_ref(py) diff --git a/tests/core/test_vm_proto_004_traceback_data.py b/tests/core/test_vm_proto_004_traceback_data.py index 3ced5cb3..89127f08 100644 --- a/tests/core/test_vm_proto_004_traceback_data.py +++ b/tests/core/test_vm_proto_004_traceback_data.py @@ -1,7 +1,10 @@ from __future__ import annotations -from doeff import Program, do +from dataclasses import dataclass + +from doeff import Effect, Pass, Program, Try, WithHandler, do from doeff.effects import Put +from doeff.effects.base import EffectBase from doeff.rust_vm import default_handlers, run @@ -14,6 +17,7 @@ def body() -> Program[int]: result = run(body(), handlers=default_handlers()) assert result.is_ok(), result.error assert result.traceback_data is None + assert result.last_active_chain == [] def test_run_result_exposes_typed_traceback_data_without_exception_dunders() -> None: @@ -52,3 +56,49 @@ def body() -> Program[object]: assert "(type: object)" in str(result.error) assert " None: + @dataclass(frozen=True, kw_only=True) + class ProbeEffect(EffectBase): + pass + + @do + def bad_handler(effect: Effect, _k: object): + if isinstance(effect, ProbeEffect): + return "bad-return" + yield Pass() + + @do + def inner() -> Program[None]: + yield ProbeEffect() + + @do + def body(): + return (yield Try(WithHandler(bad_handler, inner()))) + + result = run(body(), handlers=default_handlers()) + + assert result.is_ok(), result.error + assert result.traceback_data is None + assert result.value.is_err() + assert result.last_active_chain + + effect_entries = [ + entry + for entry in result.last_active_chain + if isinstance(entry, dict) and entry.get("kind") == "effect_yield" + ] + assert effect_entries + + assert any(entry["result"]["kind"] == "threw" for entry in effect_entries) + assert any( + "handler returned without consuming continuation" in entry["result"]["exception_repr"] + for entry in effect_entries + ) + assert any( + str(handler["handler_name"]).endswith("bad_handler") and handler["status"] == "threw" + for entry in effect_entries + for handler in entry["handler_stack"] + ) From a57add2ffc3c4747062c4e2faba4809d28bd05e3 Mon Sep 17 00:00:00 2001 From: proboscis Date: Mon, 16 Mar 2026 23:22:06 +0900 Subject: [PATCH 2/4] Add early termination warnings --- doeff/_types_internal.py | 3 + doeff/rust_vm.py | 70 ++++++++++++ packages/doeff-vm-core/src/vm.rs | 49 +++++++++ packages/doeff-vm-core/src/vm/step.rs | 5 + packages/doeff-vm/doeff_vm/__init__.pyi | 1 + packages/doeff-vm/src/pyvm.rs | 16 +++ tests/core/test_doeff_trace_stderr.py | 63 +++++++++++ tests/core/test_rust_vm_api_strict.py | 103 ++++++++++++++++++ .../core/test_vm_proto_004_traceback_data.py | 28 +++++ 9 files changed, 338 insertions(+) diff --git a/doeff/_types_internal.py b/doeff/_types_internal.py index b1d4dcef..3d8f921a 100644 --- a/doeff/_types_internal.py +++ b/doeff/_types_internal.py @@ -695,6 +695,9 @@ def traceback_data(self) -> Any | None: ... @property def last_active_chain(self) -> Any: ... + @property + def early_terminated(self) -> bool: ... + def is_ok(self) -> bool: ... def is_err(self) -> bool: ... diff --git a/doeff/rust_vm.py b/doeff/rust_vm.py index 67d134a7..68c9e603 100644 --- a/doeff/rust_vm.py +++ b/doeff/rust_vm.py @@ -412,6 +412,73 @@ def _print_doeff_trace(doeff_tb: Any | None) -> None: return +_EARLY_TERMINATION_MESSAGE = "Program terminated early before the root program completed." + + +def _early_termination_exception(run_result: Any) -> BaseException: + try: + value = run_result.value + except AttributeError: + return RuntimeError(_EARLY_TERMINATION_MESSAGE) + + try: + is_err = value.is_err + except AttributeError: + if isinstance(value, BaseException): + return value + return RuntimeError(_EARLY_TERMINATION_MESSAGE) + + if callable(is_err) and is_err(): + try: + error = value.error + except AttributeError: + return RuntimeError(_EARLY_TERMINATION_MESSAGE) + if isinstance(error, BaseException): + return error + + if isinstance(value, BaseException): + return value + return RuntimeError(_EARLY_TERMINATION_MESSAGE) + + +def _warn_early_termination_if_present(run_result: Any) -> None: + try: + early_terminated = run_result.early_terminated + except AttributeError: + return + if early_terminated is not True: + return + + try: + active_chain = run_result.last_active_chain + except AttributeError: + active_chain = () + if not isinstance(active_chain, (list, tuple)): + active_chain = () + + try: + from doeff.traceback import build_doeff_traceback + + doeff_tb = build_doeff_traceback( + _early_termination_exception(run_result), + (), + active_chain, + allow_active=True, + ) + except Exception as exc: + warnings.warn(f"Failed to build early termination warning: {exc}", stacklevel=2) + doeff_tb = None + + try: + import sys + + print(_EARLY_TERMINATION_MESSAGE, file=sys.stderr) + except Exception as exc: + warnings.warn(f"Failed to print early termination warning: {exc}", stacklevel=2) + + _print_doeff_trace(doeff_tb) + + def _run_call_kwargs( run_fn: Any, *, @@ -532,6 +599,7 @@ def run( store: dict[str, Any] | None = None, trace: bool = False, print_doeff_trace: bool = False, + warn_early_termination: bool = True, ) -> Any: vm = _vm() try: @@ -551,6 +619,8 @@ def run( doeff_tb = _build_doeff_traceback_if_present(result) if print_doeff_trace: _print_doeff_trace(doeff_tb) + if warn_early_termination: + _warn_early_termination_if_present(result) return _raise_unhandled_effect_if_present(result, raise_unhandled=raise_unhandled) diff --git a/packages/doeff-vm-core/src/vm.rs b/packages/doeff-vm-core/src/vm.rs index b8229f22..81e0cafb 100644 --- a/packages/doeff-vm-core/src/vm.rs +++ b/packages/doeff-vm-core/src/vm.rs @@ -278,6 +278,9 @@ pub struct VM { pub(crate) debug: DebugState, pub(crate) trace_state: TraceState, pub(crate) last_active_chain: Vec, + pub(crate) root_program_stream: Option, + pub(crate) root_program_completed: bool, + pub(crate) early_terminated: bool, pub continuation_registry: HashMap, pub active_run_token: Option, } @@ -296,6 +299,9 @@ impl VM { debug: DebugState::new(DebugConfig::default()), trace_state: TraceState::default(), last_active_chain: Vec::new(), + root_program_stream: None, + root_program_completed: false, + early_terminated: false, continuation_registry: HashMap::new(), active_run_token: None, } @@ -316,11 +322,54 @@ impl VM { self.active_run_token = Some(token); self.trace_state.clear(); self.last_active_chain.clear(); + self.root_program_stream = None; + self.root_program_completed = false; + self.early_terminated = false; self.interceptor_state.clear_for_run(); self.run_handlers.clear(); token } + pub(crate) fn register_root_program_stream( + &mut self, + stream: &IRStreamRef, + handler_kind: Option, + ) { + if handler_kind.is_some() || self.root_program_stream.is_some() { + return; + } + self.root_program_stream = Some(stream.clone()); + } + + pub(crate) fn maybe_mark_root_program_completed(&mut self, stream: &IRStreamRef) { + if self.root_program_completed { + return; + } + if self + .root_program_stream + .as_ref() + .is_some_and(|root_stream| Arc::ptr_eq(root_stream, stream)) + { + self.root_program_completed = true; + } + } + + pub fn has_root_program_stream(&self) -> bool { + self.root_program_stream.is_some() + } + + pub fn root_program_completed(&self) -> bool { + self.root_program_completed + } + + pub fn early_terminated(&self) -> bool { + self.early_terminated + } + + pub(crate) fn mark_early_terminated(&mut self) { + self.early_terminated = true; + } + pub fn current_run_token(&self) -> Option { self.active_run_token } diff --git a/packages/doeff-vm-core/src/vm/step.rs b/packages/doeff-vm-core/src/vm/step.rs index a5607a48..112f3966 100644 --- a/packages/doeff-vm-core/src/vm/step.rs +++ b/packages/doeff-vm-core/src/vm/step.rs @@ -435,6 +435,7 @@ impl VM { "handler returned without consuming continuation {}; use Resume(k, v), Transfer(k, v), Discontinue(k, exn), or Pass()", continuation.cont_id.raw(), )); + self.mark_early_terminated(); self.mark_one_shot_consumed(continuation.cont_id); self.emit_handler_threw_for_dispatch(dispatch_id, &exception); self.current_seg_mut().mode = self.contextual_throw_mode(exception); @@ -527,6 +528,7 @@ impl VM { dispatch_id.raw(), value, )); + self.mark_early_terminated(); self.emit_handler_threw_for_dispatch(dispatch_id, &exception); self.current_seg_mut().mode = self.contextual_throw_mode(exception); StepEvent::Continue @@ -808,6 +810,7 @@ impl VM { self.handle_stream_yield(yielded, stream, metadata, handler_kind) } IRStreamStep::Return(value) => { + self.maybe_mark_root_program_completed(&stream); if let Some(ref m) = metadata { self.emit_frame_exited(m); } @@ -1749,6 +1752,7 @@ impl VM { metadata: Option, handler_kind: Option, ) -> StepEvent { + self.register_root_program_stream(&stream, handler_kind); if let Some(ref m) = metadata { self.emit_frame_entered(m, handler_kind); } @@ -2442,6 +2446,7 @@ impl VM { let _ = self.handle_stream_yield(yielded, stream, metadata, handler_kind); } PyCallOutcome::GenReturn(value) => { + self.maybe_mark_root_program_completed(&stream); if let Some(ref m) = metadata { self.emit_frame_exited(m); } diff --git a/packages/doeff-vm/doeff_vm/__init__.pyi b/packages/doeff-vm/doeff_vm/__init__.pyi index d0c0586d..0c814333 100644 --- a/packages/doeff-vm/doeff_vm/__init__.pyi +++ b/packages/doeff-vm/doeff_vm/__init__.pyi @@ -129,6 +129,7 @@ RunResultValue: TypeAlias = Ok[_T] | Err class RunResult(Generic[_T]): traceback_data: DoeffTracebackData | None last_active_chain: Any + early_terminated: bool @property def value(self) -> _T: ... @property diff --git a/packages/doeff-vm/src/pyvm.rs b/packages/doeff-vm/src/pyvm.rs index 9c7c2233..88cb8f1e 100644 --- a/packages/doeff-vm/src/pyvm.rs +++ b/packages/doeff-vm/src/pyvm.rs @@ -471,11 +471,13 @@ impl PyVM { } let last_active_chain = self.build_last_active_chain(py, traceback_data.as_ref().map(|data| data.bind(py)))?; + let early_terminated = self.build_early_terminated(result.is_ok()); Ok(PyRunResult { result, traceback_data, last_active_chain, + early_terminated, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -604,6 +606,12 @@ impl PyVM { Ok(PyList::empty(py).into_any().unbind()) } + fn build_early_terminated(&self, result_is_ok: bool) -> bool { + result_is_ok + && (self.vm.early_terminated() + || (self.vm.has_root_program_stream() && !self.vm.root_program_completed())) + } + pub fn build_run_result( &self, py: Python<'_>, @@ -621,6 +629,7 @@ impl PyVM { result: Ok(value.unbind()), traceback_data: None, last_active_chain: self.build_last_active_chain(py, None)?, + early_terminated: self.build_early_terminated(true), raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -648,6 +657,7 @@ impl PyVM { result: Err(exc), traceback_data: traceback_data.map(Bound::unbind), last_active_chain, + early_terminated: false, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -2052,6 +2062,7 @@ pub struct PyRunResult { #[pyo3(get)] traceback_data: Option>, last_active_chain: Py, + early_terminated: bool, raw_store: Py, log: Py, trace: Py, @@ -2177,6 +2188,11 @@ impl PyRunResult { self.last_active_chain.clone_ref(py) } + #[getter] + fn early_terminated(&self) -> bool { + self.early_terminated + } + #[getter] fn log(&self, py: Python<'_>) -> Py { self.log.clone_ref(py) diff --git a/tests/core/test_doeff_trace_stderr.py b/tests/core/test_doeff_trace_stderr.py index cd6672f0..af348cce 100644 --- a/tests/core/test_doeff_trace_stderr.py +++ b/tests/core/test_doeff_trace_stderr.py @@ -10,6 +10,7 @@ Effect, EffectBase, Program, + Try, WithHandler, async_run, default_async_handlers, @@ -97,6 +98,68 @@ def ok() -> Program[int]: assert captured.err == "" +def test_run_warns_on_caught_early_termination(capsys: pytest.CaptureFixture[str]) -> None: + @dataclass(frozen=True, kw_only=True) + class Boom(EffectBase): + pass + + @do + def bad_handler(effect: Effect, _k: object): + if isinstance(effect, Boom): + return "bad-return" + yield Delegate() + + @do + def inner() -> Program[None]: + yield Boom() + + @do + def body(): + return (yield Try(WithHandler(bad_handler, inner()))) + + result = run(body(), handlers=default_handlers()) + assert result.is_ok() + assert result.early_terminated is True + + captured = capsys.readouterr() + assert "Program terminated early before the root program completed." in captured.err + assert "doeff Traceback" in captured.err + assert "bad_handler" in captured.err + + +def test_run_can_suppress_caught_early_termination_warning( + capsys: pytest.CaptureFixture[str], +) -> None: + @dataclass(frozen=True, kw_only=True) + class Boom(EffectBase): + pass + + @do + def bad_handler(effect: Effect, _k: object): + if isinstance(effect, Boom): + return "bad-return" + yield Delegate() + + @do + def inner() -> Program[None]: + yield Boom() + + @do + def body(): + return (yield Try(WithHandler(bad_handler, inner()))) + + result = run( + body(), + handlers=default_handlers(), + warn_early_termination=False, + ) + assert result.is_ok() + assert result.early_terminated is True + + captured = capsys.readouterr() + assert captured.err == "" + + def test_doeff_trace_renders_active_chain(capsys: pytest.CaptureFixture[str]) -> None: @dataclass(frozen=True, kw_only=True) class Boom(EffectBase): diff --git a/tests/core/test_rust_vm_api_strict.py b/tests/core/test_rust_vm_api_strict.py index ef35a183..76827a4b 100644 --- a/tests/core/test_rust_vm_api_strict.py +++ b/tests/core/test_rust_vm_api_strict.py @@ -194,6 +194,109 @@ def format_default() -> str: rust_vm_module._print_doeff_trace(BrokenTrace()) +def test_run_warns_on_early_termination( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class ErrValueStub: + error = RuntimeError("handler returned without consuming continuation") + + @staticmethod + def is_err() -> bool: + return True + + class RunResultStub: + early_terminated = True + last_active_chain = [ + { + "kind": "effect_yield", + "function_name": "body", + "source_file": "test_program.py", + "source_line": 12, + "effect_repr": "ProbeEffect()", + "handler_stack": [ + { + "handler_name": "bad_handler", + "handler_kind": "python", + "source_file": "handlers.py", + "source_line": 7, + "status": "threw", + } + ], + "result": { + "kind": "threw", + "handler_name": "bad_handler", + "exception_repr": "RuntimeError('handler returned without consuming continuation')", + }, + } + ] + value = ErrValueStub() + + @staticmethod + def is_err() -> bool: + return False + + def fake_run( + program: object, + *, + env: dict[str, object] | None, + store: dict[str, object] | None, + ) -> RunResultStub: + return RunResultStub() + + fake_vm = SimpleNamespace( + run=fake_run, + EffectBase=doeff_vm.EffectBase, + DoExpr=doeff_vm.DoExpr, + Perform=doeff_vm.Perform, + ) + monkeypatch.setattr(rust_vm_module, "_vm", lambda: fake_vm) + + result = rust_vm_module.run(Program.pure(1), handlers=[]) + + assert result.early_terminated is True + captured = capsys.readouterr() + assert "Program terminated early before the root program completed." in captured.err + assert "doeff Traceback" in captured.err + assert "bad_handler" in captured.err + + +def test_run_can_suppress_early_termination_warning( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class RunResultStub: + early_terminated = True + last_active_chain = [] + value = object() + + @staticmethod + def is_err() -> bool: + return False + + def fake_run( + program: object, + *, + env: dict[str, object] | None, + store: dict[str, object] | None, + ) -> RunResultStub: + return RunResultStub() + + fake_vm = SimpleNamespace( + run=fake_run, + EffectBase=doeff_vm.EffectBase, + DoExpr=doeff_vm.DoExpr, + Perform=doeff_vm.Perform, + ) + monkeypatch.setattr(rust_vm_module, "_vm", lambda: fake_vm) + + result = rust_vm_module.run(Program.pure(1), handlers=[], warn_early_termination=False) + + assert result.early_terminated is True + captured = capsys.readouterr() + assert captured.err == "" + + def test_raise_unhandled_effect_uses_typed_exception_classes( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/core/test_vm_proto_004_traceback_data.py b/tests/core/test_vm_proto_004_traceback_data.py index 89127f08..1140465e 100644 --- a/tests/core/test_vm_proto_004_traceback_data.py +++ b/tests/core/test_vm_proto_004_traceback_data.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +import doeff_vm + from doeff import Effect, Pass, Program, Try, WithHandler, do from doeff.effects import Put from doeff.effects.base import EffectBase @@ -18,6 +20,7 @@ def body() -> Program[int]: assert result.is_ok(), result.error assert result.traceback_data is None assert result.last_active_chain == [] + assert result.early_terminated is False def test_run_result_exposes_typed_traceback_data_without_exception_dunders() -> None: @@ -83,6 +86,7 @@ def body(): assert result.is_ok(), result.error assert result.traceback_data is None assert result.value.is_err() + assert result.early_terminated is True assert result.last_active_chain effect_entries = [ @@ -102,3 +106,27 @@ def body(): for entry in effect_entries for handler in entry["handler_stack"] ) + + +def test_build_run_result_marks_early_terminated_when_root_program_is_unfinished() -> None: + vm = doeff_vm.PyVM() + + @do + def body() -> Program[int]: + value = yield Program.pure(7) + return value + + vm.start_program(body()) + + result = None + for _ in range(4): + step = vm.step_once() + assert step[0] in {"continue", "done"} + result = vm.build_run_result(123) + if result.early_terminated: + break + + assert result is not None + assert result.is_ok(), result.error + assert result.value == 123 + assert result.early_terminated is True From 8a56f35f774a42466d1dd483834b898ca8a01281 Mon Sep 17 00:00:00 2001 From: proboscis Date: Mon, 16 Mar 2026 23:38:52 +0900 Subject: [PATCH 3/4] Cover async early termination warnings --- doeff/rust_vm.py | 3 + packages/doeff-vm-core/src/vm/dispatch.rs | 2 +- packages/doeff-vm-core/src/vm/step.rs | 4 +- packages/doeff-vm-core/src/vm/vm_trace.rs | 15 ++- tests/core/test_doeff_trace_stderr.py | 66 +++++++++++++ tests/core/test_rust_vm_api_strict.py | 109 ++++++++++++++++++++++ 6 files changed, 193 insertions(+), 6 deletions(-) diff --git a/doeff/rust_vm.py b/doeff/rust_vm.py index 68c9e603..76cb5079 100644 --- a/doeff/rust_vm.py +++ b/doeff/rust_vm.py @@ -631,6 +631,7 @@ async def async_run( store: dict[str, Any] | None = None, trace: bool = False, print_doeff_trace: bool = False, + warn_early_termination: bool = True, ) -> Any: vm = _vm() try: @@ -650,6 +651,8 @@ async def async_run( doeff_tb = _build_doeff_traceback_if_present(result) if print_doeff_trace: _print_doeff_trace(doeff_tb) + if warn_early_termination: + _warn_early_termination_if_present(result) return _raise_unhandled_effect_if_present(result, raise_unhandled=raise_unhandled) diff --git a/packages/doeff-vm-core/src/vm/dispatch.rs b/packages/doeff-vm-core/src/vm/dispatch.rs index e59084fc..77c71abf 100644 --- a/packages/doeff-vm-core/src/vm/dispatch.rs +++ b/packages/doeff-vm-core/src/vm/dispatch.rs @@ -1382,7 +1382,7 @@ impl VM { .into_iter() .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) .collect(); - self.set_last_active_chain(active_chain.clone()); + self.set_last_active_chain(&active_chain); let enriched_exception = match TraceState::enrich_original_exception_with_context( original_exception, value, diff --git a/packages/doeff-vm-core/src/vm/step.rs b/packages/doeff-vm-core/src/vm/step.rs index 112f3966..0c105938 100644 --- a/packages/doeff-vm-core/src/vm/step.rs +++ b/packages/doeff-vm-core/src/vm/step.rs @@ -178,7 +178,7 @@ impl VM { self.finalize_active_dispatches_as_threw(&exc); let trace = self.assemble_traceback_entries(&exc); let active_chain = self.assemble_active_chain(Some(&exc)); - self.set_last_active_chain(active_chain.clone()); + self.set_last_active_chain(&active_chain); self.segments.reparent_children(seg_id, None); self.segments.free(seg_id); self.current_segment = None; @@ -500,7 +500,7 @@ impl VM { .into_iter() .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) .collect(); - self.set_last_active_chain(active_chain.clone()); + self.set_last_active_chain(&active_chain); self.current_seg_mut().mode = match TraceState::enrich_original_exception_with_context( original, diff --git a/packages/doeff-vm-core/src/vm/vm_trace.rs b/packages/doeff-vm-core/src/vm/vm_trace.rs index 84cd9e61..e275ddec 100644 --- a/packages/doeff-vm-core/src/vm/vm_trace.rs +++ b/packages/doeff-vm-core/src/vm/vm_trace.rs @@ -432,12 +432,21 @@ impl VM { ) } + fn clone_last_active_chain_entries(active_chain: &[ActiveChainEntry]) -> Vec { + active_chain + .iter() + .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) + .cloned() + .collect() + } + pub(crate) fn capture_last_active_chain(&mut self, exception: Option<&PyException>) { - self.last_active_chain = self.assemble_active_chain(exception); + let active_chain = self.assemble_active_chain(exception); + self.set_last_active_chain(&active_chain); } - pub(crate) fn set_last_active_chain(&mut self, active_chain: Vec) { - self.last_active_chain = active_chain; + pub(crate) fn set_last_active_chain(&mut self, active_chain: &[ActiveChainEntry]) { + self.last_active_chain = Self::clone_last_active_chain_entries(active_chain); } pub fn last_active_chain(&self) -> &[ActiveChainEntry] { diff --git a/tests/core/test_doeff_trace_stderr.py b/tests/core/test_doeff_trace_stderr.py index af348cce..f0495788 100644 --- a/tests/core/test_doeff_trace_stderr.py +++ b/tests/core/test_doeff_trace_stderr.py @@ -86,6 +86,72 @@ def failing() -> Program[int]: assert "nonexistent_key" in captured.err +@pytest.mark.asyncio +async def test_async_run_warns_on_caught_early_termination( + capsys: pytest.CaptureFixture[str], +) -> None: + @dataclass(frozen=True, kw_only=True) + class Boom(EffectBase): + pass + + @do + def bad_handler(effect: Effect, _k: object): + if isinstance(effect, Boom): + return "bad-return" + yield Delegate() + + @do + def inner() -> Program[None]: + yield Boom() + + @do + def body(): + return (yield Try(WithHandler(bad_handler, inner()))) + + result = await async_run(body(), handlers=default_async_handlers()) + assert result.is_ok() + assert result.early_terminated is True + + captured = capsys.readouterr() + assert "Program terminated early before the root program completed." in captured.err + assert "doeff Traceback" in captured.err + assert "bad_handler" in captured.err + + +@pytest.mark.asyncio +async def test_async_run_can_suppress_caught_early_termination_warning( + capsys: pytest.CaptureFixture[str], +) -> None: + @dataclass(frozen=True, kw_only=True) + class Boom(EffectBase): + pass + + @do + def bad_handler(effect: Effect, _k: object): + if isinstance(effect, Boom): + return "bad-return" + yield Delegate() + + @do + def inner() -> Program[None]: + yield Boom() + + @do + def body(): + return (yield Try(WithHandler(bad_handler, inner()))) + + result = await async_run( + body(), + handlers=default_async_handlers(), + warn_early_termination=False, + ) + assert result.is_ok() + assert result.early_terminated is True + + captured = capsys.readouterr() + assert captured.err == "" + + def test_run_no_stderr_on_success(capsys: pytest.CaptureFixture[str]) -> None: @do def ok() -> Program[int]: diff --git a/tests/core/test_rust_vm_api_strict.py b/tests/core/test_rust_vm_api_strict.py index 76827a4b..dbacb00d 100644 --- a/tests/core/test_rust_vm_api_strict.py +++ b/tests/core/test_rust_vm_api_strict.py @@ -297,6 +297,115 @@ def fake_run( assert captured.err == "" +@pytest.mark.asyncio +async def test_async_run_warns_on_early_termination( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class ErrValueStub: + error = RuntimeError("handler returned without consuming continuation") + + @staticmethod + def is_err() -> bool: + return True + + class RunResultStub: + early_terminated = True + last_active_chain = [ + { + "kind": "effect_yield", + "function_name": "body", + "source_file": "test_program.py", + "source_line": 12, + "effect_repr": "ProbeEffect()", + "handler_stack": [ + { + "handler_name": "bad_handler", + "handler_kind": "python", + "source_file": "handlers.py", + "source_line": 7, + "status": "threw", + } + ], + "result": { + "kind": "threw", + "handler_name": "bad_handler", + "exception_repr": "RuntimeError('handler returned without consuming continuation')", + }, + } + ] + value = ErrValueStub() + + @staticmethod + def is_err() -> bool: + return False + + async def fake_async_run( + program: object, + *, + env: dict[str, object] | None, + store: dict[str, object] | None, + ) -> RunResultStub: + return RunResultStub() + + fake_vm = SimpleNamespace( + async_run=fake_async_run, + EffectBase=doeff_vm.EffectBase, + DoExpr=doeff_vm.DoExpr, + Perform=doeff_vm.Perform, + ) + monkeypatch.setattr(rust_vm_module, "_vm", lambda: fake_vm) + + result = await rust_vm_module.async_run(Program.pure(1), handlers=[]) + + assert result.early_terminated is True + captured = capsys.readouterr() + assert "Program terminated early before the root program completed." in captured.err + assert "doeff Traceback" in captured.err + assert "bad_handler" in captured.err + + +@pytest.mark.asyncio +async def test_async_run_can_suppress_early_termination_warning( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class RunResultStub: + early_terminated = True + last_active_chain = [] + value = object() + + @staticmethod + def is_err() -> bool: + return False + + async def fake_async_run( + program: object, + *, + env: dict[str, object] | None, + store: dict[str, object] | None, + ) -> RunResultStub: + return RunResultStub() + + fake_vm = SimpleNamespace( + async_run=fake_async_run, + EffectBase=doeff_vm.EffectBase, + DoExpr=doeff_vm.DoExpr, + Perform=doeff_vm.Perform, + ) + monkeypatch.setattr(rust_vm_module, "_vm", lambda: fake_vm) + + result = await rust_vm_module.async_run( + Program.pure(1), + handlers=[], + warn_early_termination=False, + ) + + assert result.early_terminated is True + captured = capsys.readouterr() + assert captured.err == "" + + def test_raise_unhandled_effect_uses_typed_exception_classes( monkeypatch: pytest.MonkeyPatch, ) -> None: From 9a4e9e854876639b997e0d8bb8fced0580fa5efc Mon Sep 17 00:00:00 2001 From: proboscis Date: Tue, 17 Mar 2026 00:28:26 +0900 Subject: [PATCH 4/4] Polish early termination helpers --- doeff/rust_vm.py | 27 +++++++++-------------- packages/doeff-vm-core/src/vm.rs | 1 + packages/doeff-vm-core/src/vm/vm_trace.rs | 6 +++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/doeff/rust_vm.py b/doeff/rust_vm.py index 76cb5079..e25e05e1 100644 --- a/doeff/rust_vm.py +++ b/doeff/rust_vm.py @@ -418,26 +418,21 @@ def _print_doeff_trace(doeff_tb: Any | None) -> None: def _early_termination_exception(run_result: Any) -> BaseException: try: value = run_result.value - except AttributeError: - return RuntimeError(_EARLY_TERMINATION_MESSAGE) - - try: - is_err = value.is_err - except AttributeError: if isinstance(value, BaseException): return value - return RuntimeError(_EARLY_TERMINATION_MESSAGE) - - if callable(is_err) and is_err(): try: - error = value.error + is_err = value.is_err except AttributeError: - return RuntimeError(_EARLY_TERMINATION_MESSAGE) - if isinstance(error, BaseException): - return error - - if isinstance(value, BaseException): - return value + is_err = None + if callable(is_err) and is_err(): + try: + error = value.error + except AttributeError: + error = None + if isinstance(error, BaseException): + return error + except Exception: + pass return RuntimeError(_EARLY_TERMINATION_MESSAGE) diff --git a/packages/doeff-vm-core/src/vm.rs b/packages/doeff-vm-core/src/vm.rs index 81e0cafb..3a3b9cee 100644 --- a/packages/doeff-vm-core/src/vm.rs +++ b/packages/doeff-vm-core/src/vm.rs @@ -351,6 +351,7 @@ impl VM { .is_some_and(|root_stream| Arc::ptr_eq(root_stream, stream)) { self.root_program_completed = true; + self.root_program_stream = None; } } diff --git a/packages/doeff-vm-core/src/vm/vm_trace.rs b/packages/doeff-vm-core/src/vm/vm_trace.rs index e275ddec..4115e115 100644 --- a/packages/doeff-vm-core/src/vm/vm_trace.rs +++ b/packages/doeff-vm-core/src/vm/vm_trace.rs @@ -432,7 +432,9 @@ impl VM { ) } - fn clone_last_active_chain_entries(active_chain: &[ActiveChainEntry]) -> Vec { + fn snapshot_active_chain_without_context( + active_chain: &[ActiveChainEntry], + ) -> Vec { active_chain .iter() .filter(|entry| !matches!(entry, ActiveChainEntry::ContextEntry { .. })) @@ -446,7 +448,7 @@ impl VM { } pub(crate) fn set_last_active_chain(&mut self, active_chain: &[ActiveChainEntry]) { - self.last_active_chain = Self::clone_last_active_chain_entries(active_chain); + self.last_active_chain = Self::snapshot_active_chain_without_context(active_chain); } pub fn last_active_chain(&self) -> &[ActiveChainEntry] {