From 0f76418835fd2ef5f6d77e3cdddf7e2cf4218ab1 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:29:04 -0500 Subject: [PATCH] Suspend sys.monitoring events inside sandbox importer sys.monitoring callbacks (PEP 669) are global and fire on all threads. When tools like coverage register branch callbacks, they can trigger lazy imports inside the sandbox on Python 3.14+, causing RestrictedWorkflowAccessError and hanging the workflow. Suspend all sys.monitoring events while the sandbox importer is active and restore them on exit. Uses reference counting so concurrent sandbox activations from multiple worker threads correctly share state. Fixes #1326 Co-Authored-By: Claude Opus 4.6 --- .../worker/workflow_sandbox/_importer.py | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/temporalio/worker/workflow_sandbox/_importer.py b/temporalio/worker/workflow_sandbox/_importer.py index 010c0c082..79afd7e0a 100644 --- a/temporalio/worker/workflow_sandbox/_importer.py +++ b/temporalio/worker/workflow_sandbox/_importer.py @@ -38,6 +38,58 @@ logger = logging.getLogger(__name__) + +class _SuspendMonitoring: + """Suspend sys.monitoring events while the sandbox importer is active. + + sys.monitoring callbacks are global (fire on all threads). If a callback + (e.g. coverage's branch tracer) triggers a lazy import while the sandbox + importer is active on this thread, the sandbox intercepts it and the + workflow fails. Suspending monitoring prevents this. + + Uses reference counting so concurrent sandbox activations (from multiple + worker threads) correctly share the global monitoring state. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._count = 0 + self._saved: dict[int, int] = {} + + def __enter__(self) -> _SuspendMonitoring: + monitoring = getattr(sys, "monitoring", None) + if monitoring is None: + return self + with self._lock: + self._count += 1 + if self._count == 1: + for tool_id in range(6): + try: + events = monitoring.get_events(tool_id) + if events: + self._saved[tool_id] = events + monitoring.set_events(tool_id, 0) + except ValueError: + pass + return self + + def __exit__(self, *args: object) -> None: + monitoring = getattr(sys, "monitoring", None) + if monitoring is None: + return + with self._lock: + self._count -= 1 + if self._count == 0: + for tool_id, events in self._saved.items(): + try: + monitoring.set_events(tool_id, events) + except ValueError: + pass + self._saved.clear() + + +_suspend_monitoring = _SuspendMonitoring() + # Set to true to log lots of sandbox details LOG_TRACE = False _trace_depth = 0 @@ -147,7 +199,8 @@ def applied(self) -> Iterator[None]: self.import_func, # type: ignore[reportArgumentType] ): with self._builtins_restricted(): - yield None + with _suspend_monitoring: + yield None finally: Importer._thread_local_current.importer = orig_importer