diff --git a/doeff/_types_internal.py b/doeff/_types_internal.py index d6ff8fba..3d8f921a 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,12 @@ def error(self) -> BaseException: ... @property 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..e25e05e1 100644 --- a/doeff/rust_vm.py +++ b/doeff/rust_vm.py @@ -412,6 +412,68 @@ 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 + if isinstance(value, BaseException): + return value + try: + is_err = value.is_err + except AttributeError: + 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) + + +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 +594,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 +614,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) @@ -561,6 +626,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: @@ -580,6 +646,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.rs b/packages/doeff-vm-core/src/vm.rs index 0d59a0a7..3a3b9cee 100644 --- a/packages/doeff-vm-core/src/vm.rs +++ b/packages/doeff-vm-core/src/vm.rs @@ -277,6 +277,10 @@ pub struct VM { pub current_segment: Option, 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, } @@ -294,6 +298,10 @@ impl VM { current_segment: None, 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, } @@ -313,11 +321,56 @@ 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.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; + self.root_program_stream = None; + } + } + + 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/dispatch.rs b/packages/doeff-vm-core/src/vm/dispatch.rs index 1dad32a5..77c71abf 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); 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..0c105938 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); self.segments.reparent_children(seg_id, None); self.segments.free(seg_id); self.current_segment = None; @@ -440,11 +431,15 @@ 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_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); + return StepEvent::Continue; } if let Err(err) = self @@ -500,11 +495,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); self.current_seg_mut().mode = match TraceState::enrich_original_exception_with_context( original, @@ -527,11 +523,15 @@ 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.mark_early_terminated(); + self.emit_handler_threw_for_dispatch(dispatch_id, &exception); + self.current_seg_mut().mode = self.contextual_throw_mode(exception); + StepEvent::Continue } } } @@ -810,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); } @@ -1751,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); } @@ -2444,12 +2446,26 @@ 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); } 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..4115e115 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,29 @@ impl VM { ) } + fn snapshot_active_chain_without_context( + 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>) { + 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: &[ActiveChainEntry]) { + self.last_active_chain = Self::snapshot_active_chain_without_context(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..0c814333 100644 --- a/packages/doeff-vm/doeff_vm/__init__.pyi +++ b/packages/doeff-vm/doeff_vm/__init__.pyi @@ -128,6 +128,8 @@ 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 fbea1ee0..88cb8f1e 100644 --- a/packages/doeff-vm/src/pyvm.rs +++ b/packages/doeff-vm/src/pyvm.rs @@ -469,10 +469,15 @@ 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)))?; + 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)?, @@ -580,6 +585,33 @@ 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()) + } + + 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<'_>, @@ -596,6 +628,8 @@ impl PyVM { Ok(PyRunResult { 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)?, @@ -618,9 +652,12 @@ 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, + early_terminated: false, raw_store: raw_store.unbind(), log: log_list.into_any().unbind(), trace: self.build_trace_list(py)?, @@ -2024,6 +2061,8 @@ pub struct PyRunResult { result: Result, PyException>, #[pyo3(get)] traceback_data: Option>, + last_active_chain: Py, + early_terminated: bool, raw_store: Py, log: Py, trace: Py, @@ -2144,6 +2183,16 @@ 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 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..f0495788 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, @@ -85,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]: @@ -97,6 +164,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..dbacb00d 100644 --- a/tests/core/test_rust_vm_api_strict.py +++ b/tests/core/test_rust_vm_api_strict.py @@ -194,6 +194,218 @@ 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 == "" + + +@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: diff --git a/tests/core/test_vm_proto_004_traceback_data.py b/tests/core/test_vm_proto_004_traceback_data.py index 3ced5cb3..1140465e 100644 --- a/tests/core/test_vm_proto_004_traceback_data.py +++ b/tests/core/test_vm_proto_004_traceback_data.py @@ -1,7 +1,12 @@ from __future__ import annotations -from doeff import Program, do +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 from doeff.rust_vm import default_handlers, run @@ -14,6 +19,8 @@ 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 == [] + assert result.early_terminated is False def test_run_result_exposes_typed_traceback_data_without_exception_dunders() -> None: @@ -52,3 +59,74 @@ 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.early_terminated is True + 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"] + ) + + +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