Skip to content

Commit e161ee3

Browse files
committed
Allow breakpoint() and input() through the sandbox in debug_mode
The workflow sandbox restricts the `breakpoint` and `input` builtins as non-deterministic. With debug_mode set the user has explicitly opted into single-stepping (and hence non-determinism for the duration of the debug session), so calling breakpoint() in sandboxed workflow code should reach the debugger rather than raising a RestrictedWorkflowAccessError. Adds a `_relax_sandbox_for_debugger` helper that, when debug_mode is enabled and the runner is a SandboxedWorkflowRunner, rebuilds the restrictions with `breakpoint` and `input` removed from the `__builtins__` invalid-module-members set. Other restrictions are untouched - this is a targeted relaxation, not full sandbox bypass. Together with the existing dispatch fix (which moves activation onto the main asyncio thread under debug_mode) this means a user can drop `breakpoint()` into any workflow - sandboxed or not - and get an interactive pdb prompt without swapping to UnsandboxedWorkflowRunner. Adds test_breakpoint_works_in_sandboxed_workflow_in_debug_mode which exercises the full path: sandboxed workflow + breakpoint() + worker hook + main-thread placement. The test substitutes the captured _ORIGINAL_BREAKPOINTHOOK with a stub so CI doesn't try to drive a real pdb REPL. 4/4 tests pass on Python 3.13 and 3.14 locally. No regressions in tests/worker/test_worker.py.
1 parent f0bc1ae commit e161ee3

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

temporalio/worker/_workflow.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ def _install_workflow_breakpoint_hook() -> None:
7171
sys.breakpointhook = _temporal_workflow_breakpoint_hook
7272

7373

74+
def _relax_sandbox_for_debugger(workflow_runner: WorkflowRunner) -> WorkflowRunner:
75+
"""Remove the sandbox restrictions on `breakpoint` and `input` so pdb /
76+
breakpoint() can be used inside sandboxed workflow code under debug_mode.
77+
78+
The sandbox flags both as non-deterministic builtins by default. Users
79+
who opt into debug_mode have explicitly accepted non-determinism for a
80+
debugging session, so we relax these specific restrictions instead of
81+
forcing users to switch to `UnsandboxedWorkflowRunner` (which also
82+
disables every other sandbox check, a larger blast radius).
83+
84+
Returns the runner unchanged if it isn't a SandboxedWorkflowRunner.
85+
"""
86+
# Import lazily so users without the sandbox module aren't penalized.
87+
from temporalio.worker.workflow_sandbox._runner import SandboxedWorkflowRunner
88+
89+
if not isinstance(workflow_runner, SandboxedWorkflowRunner):
90+
return workflow_runner
91+
92+
restrictions = workflow_runner.restrictions
93+
invalid = restrictions.invalid_module_members
94+
builtins_matcher = invalid.children.get("__builtins__")
95+
if builtins_matcher is None or not (
96+
"breakpoint" in builtins_matcher.use or "input" in builtins_matcher.use
97+
):
98+
return workflow_runner
99+
100+
new_use = set(builtins_matcher.use) - {"breakpoint", "input"}
101+
new_builtins = dataclasses.replace(builtins_matcher, use=new_use)
102+
new_invalid = dataclasses.replace(
103+
invalid, children={**invalid.children, "__builtins__": new_builtins}
104+
)
105+
new_restrictions = dataclasses.replace(
106+
restrictions, invalid_module_members=new_invalid
107+
)
108+
return dataclasses.replace(workflow_runner, restrictions=new_restrictions)
109+
110+
74111
# Value was chosen abitrarily as a small number that allows some concurrency and prevents
75112
# large numbers of concurrent external storage operations causing resource contention.
76113
# This default limit is per workflow task activation and does not limit the total number
@@ -119,6 +156,15 @@ def __init__(
119156
)
120157
)
121158
self._workflow_task_executor_user_provided = workflow_task_executor is not None
159+
160+
# Debug mode also enabled by the TEMPORAL_DEBUG env var. In debug mode,
161+
# deadlock detection is disabled, workflow activations run inline on
162+
# the asyncio main thread so interactive debuggers (pdb, breakpoint(),
163+
# IDE debuggers) can read stdin, and the sandbox restriction on
164+
# `breakpoint`/`input` is lifted so calls reach the debugger.
165+
self._debug_mode = bool(debug_mode or os.environ.get("TEMPORAL_DEBUG"))
166+
if self._debug_mode:
167+
workflow_runner = _relax_sandbox_for_debugger(workflow_runner)
122168
self._workflow_runner = workflow_runner
123169
self._unsandboxed_workflow_runner = unsandboxed_workflow_runner
124170
self._data_converter = data_converter
@@ -150,11 +196,8 @@ def __init__(
150196
)
151197
self._throw_after_activation: Exception | None = None
152198

153-
# Debug mode also enabled by the TEMPORAL_DEBUG env var. In debug mode,
154-
# deadlock detection is disabled and workflow activations run inline on
155-
# the asyncio main thread so interactive debuggers (pdb, breakpoint(),
156-
# IDE debuggers) can read stdin.
157-
self._debug_mode = bool(debug_mode or os.environ.get("TEMPORAL_DEBUG"))
199+
# self._debug_mode is set earlier (before workflow_runner is assigned)
200+
# so the sandbox relaxation can take effect on the runner.
158201
self._deadlock_timeout_seconds = None if self._debug_mode else 2
159202

160203
# Install a process-wide breakpoint hook that fails loudly when

tests/worker/test_breakpoint_hang.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
import sys
1818
import threading
1919
import uuid
20+
from unittest.mock import patch
2021

2122
from temporalio import workflow
2223
from temporalio.client import Client
2324
from temporalio.worker import Worker
25+
from temporalio.worker import _workflow as _workflow_module
2426
from temporalio.worker._workflow import _temporal_workflow_breakpoint_hook
2527

2628

@@ -87,6 +89,61 @@ async def test_workflow_runs_on_main_thread_in_debug_mode(client: Client):
8789
)
8890

8991

92+
@workflow.defn
93+
class SandboxedBreakpointWorkflow:
94+
"""Sandboxed workflow that calls breakpoint() — verifies the fix works
95+
without requiring users to switch to UnsandboxedWorkflowRunner."""
96+
97+
@workflow.run
98+
async def run(self) -> str:
99+
breakpoint()
100+
return "done"
101+
102+
103+
async def test_breakpoint_works_in_sandboxed_workflow_in_debug_mode(client: Client):
104+
"""`breakpoint()` inside a *sandboxed* workflow should reach the debugger
105+
when `debug_mode=True` is set. This is the natural unit-test scenario:
106+
users want to drop `breakpoint()` into their workflow without having to
107+
swap the runner.
108+
109+
We can't drive a real pdb REPL in CI, so we substitute the captured
110+
`_ORIGINAL_BREAKPOINTHOOK` (which our worker hook delegates to after the
111+
thread check) with a stub that records the call. If the stub is reached,
112+
we've proven the path from `breakpoint()` → our hook → original hook
113+
works through the sandbox, on the main thread.
114+
"""
115+
captured: dict[str, object] = {}
116+
117+
def stub_breakpointhook(*args: object, **kwargs: object) -> None:
118+
captured["thread"] = threading.current_thread().name
119+
captured["called"] = True
120+
121+
task_queue = f"tq-{uuid.uuid4()}"
122+
with patch.object(
123+
_workflow_module, "_ORIGINAL_BREAKPOINTHOOK", stub_breakpointhook
124+
):
125+
async with Worker(
126+
client,
127+
task_queue=task_queue,
128+
workflows=[SandboxedBreakpointWorkflow],
129+
debug_mode=True,
130+
):
131+
result = await client.execute_workflow(
132+
SandboxedBreakpointWorkflow.run,
133+
id=f"wf-{uuid.uuid4()}",
134+
task_queue=task_queue,
135+
)
136+
137+
assert result == "done", (
138+
f"workflow did not complete; breakpoint() likely raised inside the sandbox: "
139+
f"result={result!r}"
140+
)
141+
assert captured.get("called"), "breakpoint hook was never reached"
142+
assert captured["thread"] == threading.main_thread().name, (
143+
f"breakpoint hook ran on {captured['thread']!r}, not the main thread"
144+
)
145+
146+
90147
def test_breakpoint_hook_raises_on_workflow_thread(client: Client):
91148
"""The defensive hook fails loudly when breakpoint() is called from a
92149
`temporal_workflow_*` thread without debug mode."""

0 commit comments

Comments
 (0)