Skip to content

Commit 0f76418

Browse files
dsfacciniclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 4e71c2e commit 0f76418

1 file changed

Lines changed: 54 additions & 1 deletion

File tree

temporalio/worker/workflow_sandbox/_importer.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,58 @@
3838

3939
logger = logging.getLogger(__name__)
4040

41+
42+
class _SuspendMonitoring:
43+
"""Suspend sys.monitoring events while the sandbox importer is active.
44+
45+
sys.monitoring callbacks are global (fire on all threads). If a callback
46+
(e.g. coverage's branch tracer) triggers a lazy import while the sandbox
47+
importer is active on this thread, the sandbox intercepts it and the
48+
workflow fails. Suspending monitoring prevents this.
49+
50+
Uses reference counting so concurrent sandbox activations (from multiple
51+
worker threads) correctly share the global monitoring state.
52+
"""
53+
54+
def __init__(self) -> None:
55+
self._lock = threading.Lock()
56+
self._count = 0
57+
self._saved: dict[int, int] = {}
58+
59+
def __enter__(self) -> _SuspendMonitoring:
60+
monitoring = getattr(sys, "monitoring", None)
61+
if monitoring is None:
62+
return self
63+
with self._lock:
64+
self._count += 1
65+
if self._count == 1:
66+
for tool_id in range(6):
67+
try:
68+
events = monitoring.get_events(tool_id)
69+
if events:
70+
self._saved[tool_id] = events
71+
monitoring.set_events(tool_id, 0)
72+
except ValueError:
73+
pass
74+
return self
75+
76+
def __exit__(self, *args: object) -> None:
77+
monitoring = getattr(sys, "monitoring", None)
78+
if monitoring is None:
79+
return
80+
with self._lock:
81+
self._count -= 1
82+
if self._count == 0:
83+
for tool_id, events in self._saved.items():
84+
try:
85+
monitoring.set_events(tool_id, events)
86+
except ValueError:
87+
pass
88+
self._saved.clear()
89+
90+
91+
_suspend_monitoring = _SuspendMonitoring()
92+
4193
# Set to true to log lots of sandbox details
4294
LOG_TRACE = False
4395
_trace_depth = 0
@@ -147,7 +199,8 @@ def applied(self) -> Iterator[None]:
147199
self.import_func, # type: ignore[reportArgumentType]
148200
):
149201
with self._builtins_restricted():
150-
yield None
202+
with _suspend_monitoring:
203+
yield None
151204
finally:
152205
Importer._thread_local_current.importer = orig_importer
153206

0 commit comments

Comments
 (0)