From 45444321f760897a07e0a9cab31e4ca53f709de8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 19 Aug 2026 14:33:10 -0700 Subject: [PATCH 1/2] workflow: support to using a shared sandbox for all workflow invocations Importing everything on every workflow invocation is quite slow, especially when using pydantic or similar. I am planning to add automatic support for using pydantic to serialize/validate arguments, and regenerating the pydantic validators on every invocation will make it even more expensive. Using a shared sandbox eliminates that problem. Using a shared sandbox means that we still get all of the benefits of preventing nondeterminstic calls, but that modifications to global variables will stay visible. But I think a good workflow shouldn't really be using globals *anyway*, so... I think I'm planning to basically immediately make a PR that will switch the default to `share_sandboxes=True`, but for cleanliness I'm separating it. To do this, I: * Add a Sandbox class entered with enter() * Add a policy flag for it * Make `Workflows` responsible for producing a sandbox * Hold a sandbox-scoped lock while *importing* the module, to avoid weird cyclic init races. * A bunch of tweaks to serde's registries Questions: * Are we nervous about sharing a sandbox for genuinely concurrent runs? If so, we can maintain a pool of unused sandboxes and create a new one when necessary. --- .../shared-sandboxes.feature.md | 1 + .../tests/unit/test_py_sandbox.py | 39 +++++- .../tests/unit/test_workflow_encryption.py | 8 +- .../tests/unit/test_workflow_serde.py | 30 ++-- .../vercel/workflow/_internal/core.py | 15 +- .../vercel/workflow/_internal/py_sandbox.py | 128 +++++++++++------- .../vercel/workflow/_internal/runtime.py | 10 +- .../vercel/workflow/_internal/serde.py | 58 ++++---- 8 files changed, 196 insertions(+), 93 deletions(-) create mode 100644 changes/vercel-workflow/shared-sandboxes.feature.md diff --git a/changes/vercel-workflow/shared-sandboxes.feature.md b/changes/vercel-workflow/shared-sandboxes.feature.md new file mode 100644 index 00000000..91a30067 --- /dev/null +++ b/changes/vercel-workflow/shared-sandboxes.feature.md @@ -0,0 +1 @@ +Add a `share_sandboxes` parameter to `SandboxPolicy` to enable reusing already created sandboxes instead of creating a new one on each invocation. This speeds up workflows but means that modifications to global state may persist between invocations. diff --git a/src/vercel-workflow/tests/unit/test_py_sandbox.py b/src/vercel-workflow/tests/unit/test_py_sandbox.py index cd19bf4f..7bc5a0c8 100644 --- a/src/vercel-workflow/tests/unit/test_py_sandbox.py +++ b/src/vercel-workflow/tests/unit/test_py_sandbox.py @@ -14,10 +14,11 @@ import platform import sys +from contextlib import contextmanager import pytest -from vercel.workflow._internal.py_sandbox import SandboxRestrictionError, workflow_sandbox +from vercel.workflow._internal.py_sandbox import Sandbox, SandboxRestrictionError from vercel.workflow.sandbox import ( ALL_CLEANUPS, SandboxCleanupContext, @@ -27,6 +28,12 @@ CLEANUP_ALL = SandboxPolicy(cleanups=ALL_CLEANUPS) +@contextmanager +def workflow_sandbox(*, policy: SandboxPolicy | None = None): + with Sandbox(policy=policy).enter(): + yield + + # ── helpers ──────────────────────────────────────────────────── @@ -1165,3 +1172,33 @@ def test_workflows_threads_policy_to_registry(self): registry = core.Workflows(as_vercel_job=False, sandbox_policy=policy) assert registry._sandbox_policy is policy assert core.Workflows(as_vercel_job=False)._sandbox_policy == SandboxPolicy() + + @staticmethod + def _modules_seen_by_two_runs(policy: SandboxPolicy | None) -> list: + from vercel.workflow._internal import core + + registry = core.Workflows(as_vercel_job=False, sandbox_policy=policy) + modules = [] + for _ in range(2): + with registry._get_sandbox() as sandbox, sandbox.enter(): + import uuid # noqa: PLC0415 + + modules.append(uuid) + return modules + + def test_shared_sandboxes_reuse_modules_across_runs(self): + """With share_sandboxes=True every run gets the one cached sandbox, + so a re-imported module is the same object run to run.""" + first, second = self._modules_seen_by_two_runs(SandboxPolicy(share_sandboxes=True)) + assert first is second + + def test_unshared_sandboxes_import_modules_freshly_each_run(self): + """With share_sandboxes=False every run gets its own sandbox, and + with it its own fresh imports.""" + first, second = self._modules_seen_by_two_runs(SandboxPolicy(share_sandboxes=False)) + assert first is not second + + def test_sandboxes_are_not_shared_by_default(self): + assert SandboxPolicy().share_sandboxes is False + first, second = self._modules_seen_by_two_runs(None) + assert first is not second diff --git a/src/vercel-workflow/tests/unit/test_workflow_encryption.py b/src/vercel-workflow/tests/unit/test_workflow_encryption.py index b1be882f..bc1d37ac 100644 --- a/src/vercel-workflow/tests/unit/test_workflow_encryption.py +++ b/src/vercel-workflow/tests/unit/test_workflow_encryption.py @@ -308,7 +308,7 @@ def test_decryption_works_inside_the_workflow_sandbox() -> None: key = encryption.derive_run_key(DEPLOYMENT_KEY, project_id=PROJECT_ID, run_id=RUN_ID) payload = seal(key, ser.dehydrate([{"amount": 21}])) - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): assert ser.hydrate(payload, what="the input of run wrun_1", key=key) == [{"amount": 21}] @@ -340,7 +340,7 @@ def _first_call_of_a_fresh_interpreter(body: str, payload: bytes) -> str: from vercel.workflow._internal import encryption, py_sandbox, serialization as ser key = encryption.derive_run_key(bytes(range(32)), project_id="prj_test", run_id="wrun_test") -with py_sandbox.workflow_sandbox(): +with py_sandbox.Sandbox().enter(): print(ser.hydrate(bytes.fromhex(sys.argv[1]), what="the input of run wrun_1", key=key)) """ @@ -349,7 +349,7 @@ def _first_call_of_a_fresh_interpreter(body: str, payload: bytes) -> str: from vercel.workflow._internal import encryption, py_sandbox key = encryption.derive_run_key(bytes(range(32)), project_id="prj_test", run_id="wrun_test") -with py_sandbox.workflow_sandbox(): +with py_sandbox.Sandbox().enter(): try: encryption.open_envelope(key, bytes.fromhex(sys.argv[1])) except encryption.DecryptionError as exc: @@ -496,7 +496,7 @@ def test_x25519_is_bound_once_at_import(monkeypatch) -> None: def test_opening_a_sealed_payload_works_inside_the_workflow_sandbox() -> None: payload = seal_to(RUN_KEY, ser.dehydrate({"type": "approve"})) - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): assert ser.hydrate(payload, what="the payload of hook hook_1", key=RUN_KEY) == { "type": "approve" } diff --git a/src/vercel-workflow/tests/unit/test_workflow_serde.py b/src/vercel-workflow/tests/unit/test_workflow_serde.py index 276899d5..0d460368 100644 --- a/src/vercel-workflow/tests/unit/test_workflow_serde.py +++ b/src/vercel-workflow/tests/unit/test_workflow_serde.py @@ -30,6 +30,13 @@ def _round_trip(value): return ser.hydrate(ser.dehydrate(value), what="a payload") +def _sandbox_registry() -> serde.Registry: + """A registry as `Sandbox` builds one: fresh, seeded with the built-ins.""" + registry = serde.Registry() + serde._register_builtins(registry) + return registry + + # ═══════════════════════════════════════════════════════════════════════════ # the wire form # ═══════════════════════════════════════════════════════════════════════════ @@ -251,7 +258,7 @@ def test_replays_do_not_accumulate_in_the_host_registry() -> None: classes, class_ids = len(serde._HOST.by_class), len(serde._HOST.by_class_id) for _ in range(50): - with serde.sandboxed_registrations(): + with serde.sandboxed_registrations(_sandbox_registry()): _define_and_register() gc.collect() @@ -270,7 +277,7 @@ def test_a_sandbox_registration_does_not_outlive_the_sandbox() -> None: """ host = _define_and_register() - with serde.sandboxed_registrations(): + with serde.sandboxed_registrations(_sandbox_registry()): sandboxed = _define_and_register() assert type(_round_trip(sandboxed())) is sandboxed, "its own class applies inside" # The classId is the same on both sides, which is what lets a payload @@ -284,7 +291,7 @@ def test_a_sandbox_registration_does_not_outlive_the_sandbox() -> None: def test_a_sandbox_registration_does_not_reach_the_host() -> None: # Nothing carries a sandbox-registered class out: a run's payloads are all # serialized inside the sandbox, its return value included. - with serde.sandboxed_registrations(): + with serde.sandboxed_registrations(_sandbox_registry()): serde.register_serializable(Point, class_id="class//sandbox-only//Point") with pytest.raises(ser.SerializationError, match="Register Point with @serializable"): @@ -302,7 +309,7 @@ def test_a_host_registration_is_not_visible_inside_a_sandbox() -> None: serde.register_serializable(Point) payload = ser.dehydrate(Point(1, 2)) - with serde.sandboxed_registrations(): + with serde.sandboxed_registrations(_sandbox_registry()): with pytest.raises(ser.SerializationError, match="unknown class"): ser.hydrate(payload, what="a payload") @@ -313,7 +320,7 @@ def test_the_sandbox_registers_the_stdlib_classes_it_imported_itself() -> None: Its `uuid.UUID` is a different class object, so a registry holding only the host's would not cover a `UUID` built inside a workflow. """ - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): import uuid as sandboxed # noqa: PLC0415 value = sandboxed.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") @@ -330,7 +337,7 @@ def test_a_restricted_stdlib_class_is_not_swapped_for_the_hosts() -> None: """ payload = ser.dehydrate(datetime.date(2026, 8, 4)) - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): import datetime as sandboxed # noqa: PLC0415 revived = ser.hydrate(payload, what="a payload") @@ -342,7 +349,7 @@ def test_a_restricted_stdlib_class_is_not_swapped_for_the_hosts() -> None: def test_the_wire_id_does_not_follow_the_sandbox_class_name() -> None: # Derived from the class it would read `class//...//_RestrictedDate`, which # the other side has never heard of. - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): import datetime as sandboxed # noqa: PLC0415 wire = _wire(sandboxed.date(2026, 8, 4)) @@ -353,7 +360,7 @@ def test_the_wire_id_does_not_follow_the_sandbox_class_name() -> None: def test_datetime_stays_native_inside_a_sandbox() -> None: # `registry.native` has to hold the sandbox's `datetime`, not the host's, # or the `date` registration would capture it through the MRO. - with py_sandbox.workflow_sandbox(): + with py_sandbox.Sandbox().enter(): import datetime as sandboxed # noqa: PLC0415 value = sandboxed.datetime(2026, 7, 30, tzinfo=datetime.timezone.utc) @@ -361,10 +368,9 @@ def test_datetime_stays_native_inside_a_sandbox() -> None: def test_built_ins_are_visible_inside_a_sandbox() -> None: - # They are registered when this module is imported, and `serde` is reached - # through to the host rather than re-imported, so a registry that started - # empty would not have them. - with serde.sandboxed_registrations(): + # Each sandbox registry is seeded with them at construction; a registry + # that started empty would not have them. + with serde.sandboxed_registrations(_sandbox_registry()): assert _round_trip(decimal.Decimal("1.50")) == decimal.Decimal("1.50") assert _round_trip(pathlib.Path("/tmp/x")) == pathlib.Path("/tmp/x") diff --git a/src/vercel-workflow/vercel/workflow/_internal/core.py b/src/vercel-workflow/vercel/workflow/_internal/core.py index 5385f4d2..90b9ca9e 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/core.py +++ b/src/vercel-workflow/vercel/workflow/_internal/core.py @@ -1,11 +1,12 @@ from __future__ import annotations +import contextlib import dataclasses import datetime import functools import inspect import random as _random -from collections.abc import AsyncIterator, Callable, Coroutine, Generator +from collections.abc import AsyncIterator, Callable, Coroutine, Generator, Iterator from typing import Any, Generic, ParamSpec, TypeVar, overload import pydantic @@ -285,12 +286,24 @@ def __init__( if sandbox_policy is None: sandbox_policy = py_sandbox.SandboxPolicy() self._sandbox_policy = sandbox_policy + + self._cached_sandbox = None + if sandbox_policy.share_sandboxes and not py_sandbox.in_sandbox(): + self._cached_sandbox = py_sandbox.Sandbox(policy=sandbox_policy, run_cleanups=False) + self._http_handler: w.HTTPHandler | None = None if as_vercel_job and not py_sandbox.in_sandbox(): from . import runtime self._http_handler = runtime.workflow_entrypoint(self) + @contextlib.contextmanager + def _get_sandbox(self) -> Iterator[py_sandbox.Sandbox]: + if self._cached_sandbox: + yield self._cached_sandbox + else: + yield py_sandbox.Sandbox(policy=self._sandbox_policy) + @property def namespace(self) -> str | None: """The immutable queue namespace for this registry.""" diff --git a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py index 1f6b8fea..40c94bce 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py +++ b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py @@ -34,8 +34,8 @@ # and writes this dict directly, so non-workflow code is unaffected. _real_sys_modules: dict[str, types.ModuleType] = sys.modules -# Per-execution module table. A workflow run sets this to its own private dict -# (see workflow_sandbox) so concurrent runs — whether on different asyncio +# Per-sandbox module table. Each sandbox sets this to its own dict (see +# Sandbox.enter) so runs in different sandboxes — whether on different asyncio # tasks or different threads — never share or clobber each other's modules. # ``None`` means "use the real table". _sandbox_sys_modules: contextvars.ContextVar[dict[str, types.ModuleType] | None] = ( @@ -692,11 +692,8 @@ class _DispatchingSysModules(MutableMapping[str, types.ModuleType]): """Installed once as ``sys.modules``. Every read/write dispatches to the current context's module table: a - workflow run's private dict while a sandbox is active in this context - (asyncio task or thread), or the real process table otherwise. The real - dict object is never cleared, so concurrent workflows on different tasks or - threads can neither corrupt each other nor the host — which the previous - ``sys.modules.clear()`` approach could not guarantee. + sandbox's private dict while a sandbox is active in this context + (asyncio task or thread), or the real process table otherwise. """ __slots__ = () @@ -850,6 +847,8 @@ def _new_sandbox_table() -> dict[str, types.ModuleType]: Everything else is served on demand by ``_SandboxFinder`` (passthrough from the host, a restricted proxy, or a fresh re-import into this table). """ + _ensure_installed() + table: dict[str, types.ModuleType] = {"sys": sys} # Snapshot atomically (list() over the view is a single C op) so a # concurrent import in another thread can't trip "dict changed size". @@ -871,11 +870,11 @@ class SandboxCleanupContext: """Handed to each cleanup handler when a sandbox exits.""" run_modules: Mapping[str, types.ModuleType] - """Snapshot of the run's private module table at exit. + """Snapshot of the sandbox's module table at exit. Values include host-shared (passthrough) modules, not just modules - imported freshly for the run. Lets a handler evict host-cache - entries that reference the run's objects instead of clearing a + imported freshly into the sandbox. Lets a handler evict host-cache + entries that reference the sandbox's objects instead of clearing a whole cache. """ @@ -926,61 +925,98 @@ def clear_pydantic_generics_cache(context: SandboxCleanupContext) -> None: class SandboxPolicy: """Configuration for the workflow sandbox, passed to ``Workflows``. - ``cleanups`` are run on the host, in order, after every sandbox - teardown; they exist to purge host-shared caches that would - otherwise pin the run's module graph (see :data:`ALL_CLEANUPS`). - A handler that raises is logged and skipped, and never masks the - workflow's own exception. Handlers must be thread-safe: other runs - may be executing concurrently. - ``passthrough_modules`` are extra modules — each name covering its submodules too — served from the host instead of re-imported per run, in addition to the built-in passthrough set. Use for large or stateful modules that are safe to share; nothing checks them for nondeterminism, and their state is shared with the host and every concurrent run. + + ``share_sandboxes`` indicates whether sandboxes can be reused for + multiple runs (including concurrent ones), or whether a + fresh one is created for each invocation. Shared sandboxes can + observe modifications made to globals by other runs, but are + much faster to launch. Default is false, but this will change. + + ``cleanups`` are run on the host, in order, after every sandbox + teardown; they exist to purge host-shared caches that would + otherwise pin the run's module graph (see :data:`ALL_CLEANUPS`). + A handler that raises is logged and skipped, and never masks the + workflow's own exception. Handlers must be thread-safe: other runs + may be executing concurrently. + These are *not* run when share_sandboxes=True. """ - cleanups: tuple[CleanupHandler, ...] = () passthrough_modules: frozenset[str] = frozenset() + share_sandboxes: bool = False + cleanups: tuple[CleanupHandler, ...] = () -# TODO: we probably want to support some form of sandbox caching -@contextmanager -def workflow_sandbox(*, policy: SandboxPolicy | None = None) -> Iterator[None]: - """Activate the workflow sandbox for the current context. +class Sandbox: + def __init__(self, *, policy: SandboxPolicy | None = None, run_cleanups: bool = True) -> None: + if policy is None: + policy = SandboxPolicy() - Gives this context its own private ``sys.modules`` table, its own - serializable-class registrations, and marks it as in-sandbox so proxy - modules enforce restrictions. All are ContextVars, so concurrent runs - are isolated without touching any shared global. - """ - if policy is None: - policy = SandboxPolicy() + self.policy = policy + self.table = _new_sandbox_table() + self.run_cleanups = run_cleanups - _ensure_installed() - table = _new_sandbox_table() - table_token = _sandbox_sys_modules.set(table) - sandbox_token = _in_sandbox.set(True) - passthrough_token = _policy_passthroughs.set(frozenset(policy.passthrough_modules)) - # Imported here rather than at module scope: `serde` is a leaf, but this - # module is imported by `core` before the rest of the package exists. - from . import serde + self.import_lock = threading.Lock() - try: - with serde.sandboxed_registrations(): - yield - finally: - _policy_passthroughs.reset(passthrough_token) - _in_sandbox.reset(sandbox_token) - _sandbox_sys_modules.reset(table_token) - context = SandboxCleanupContext(run_modules=dict(table)) - for handler in policy.cleanups: + # Imported here rather than at module scope: `serde` is a leaf, but this + # module is imported by `core` before the rest of the package exists. + from . import serde + + self.serde_registry = serde.Registry() + # In-context, so the classes registered are the sandbox's own -- its + # re-imported `uuid.UUID`, the proxied `datetime`'s `_RestrictedDate`. + with self._activate(): + serde._register_builtins(self.serde_registry) + + def cleanup(self) -> None: + context = SandboxCleanupContext(run_modules=dict(self.table)) + for handler in self.policy.cleanups: try: handler(context) except Exception: logger.exception("sandbox cleanup handler %r failed", handler) + @contextmanager + def _activate(self) -> Iterator[None]: + """Mark this context in-sandbox, on this sandbox's module table.""" + table_token = _sandbox_sys_modules.set(self.table) + sandbox_token = _in_sandbox.set(True) + passthrough_token = _policy_passthroughs.set(frozenset(self.policy.passthrough_modules)) + try: + yield + finally: + _policy_passthroughs.reset(passthrough_token) + _in_sandbox.reset(sandbox_token) + _sandbox_sys_modules.reset(table_token) + + @contextmanager + def enter(self) -> Iterator[None]: + """Activate the workflow sandbox for the current context. + + Gives this context its own private ``sys.modules`` table, its own + serializable-class registrations, and marks it as in-sandbox so proxy + modules enforce restrictions. All are ContextVars, so concurrent runs + using different sandboxes are isolated without touching any shared global. + """ + # Imported here rather than at module scope: `serde` is a leaf, but this + # module is imported by `core` before the rest of the package exists. + from . import serde + + try: + with ( + self._activate(), + serde.sandboxed_registrations(self.serde_registry), + ): + yield + finally: + if self.run_cleanups: + self.cleanup() + def in_sandbox() -> bool: return _in_sandbox.get() diff --git a/src/vercel-workflow/vercel/workflow/_internal/runtime.py b/src/vercel-workflow/vercel/workflow/_internal/runtime.py index d840f207..af9ddda0 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/runtime.py +++ b/src/vercel-workflow/vercel/workflow/_internal/runtime.py @@ -24,7 +24,6 @@ from vercel._internal.core.polyfills import UTC, Self from . import core, errors, loop, nanoid, serialization as ser, streams, ulid, world as w -from .py_sandbox import workflow_sandbox P = ParamSpec("P") T = TypeVar("T") @@ -457,8 +456,13 @@ def run_workflow(self: Self, workflow_run: w.WorkflowRun) -> bytes: if not workflow_run.input: raise RuntimeError(f"Invalid workflow input for run {workflow_run.run_id}") - with workflow_sandbox(policy=self.registry._sandbox_policy): - mod = importlib.import_module(wf.module) + with ( + self.registry._get_sandbox() as sandbox, + sandbox.enter(), + ): + # Hold a lock over the import, to avoid weird init races + with sandbox.import_lock: + mod = importlib.import_module(wf.module) # Resolve the sandboxed Workflow by qualname from the # re-imported module. diff --git a/src/vercel-workflow/vercel/workflow/_internal/serde.py b/src/vercel-workflow/vercel/workflow/_internal/serde.py index 781f7409..09451a83 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/serde.py +++ b/src/vercel-workflow/vercel/workflow/_internal/serde.py @@ -49,7 +49,7 @@ class _Registration: @dataclasses.dataclass -class _Registry: +class Registry: """The registrations one side of the sandbox boundary can see. A sandbox gets its own, holding the classes it imported itself and nothing @@ -58,7 +58,7 @@ class _Registry: by_class_id: dict[str, _Registration] = dataclasses.field(default_factory=dict) by_class: dict[type, str] = dataclasses.field(default_factory=dict) - # `type(value)` -> the classId that applies to it, or None. Dropped + # `type(value)` -> the classId that applies to it, or None. Superseded # whenever a registration lands here, since a new one can change the # answer. resolved: dict[type, str | None] = dataclasses.field(default_factory=dict) @@ -72,19 +72,22 @@ def registration(self, class_id: str) -> _Registration | None: def add(self, cls: type, registration: _Registration) -> None: self.by_class_id[registration.class_id] = registration self.by_class[cls] = registration.class_id - self.resolved.clear() + # Replaced rather than cleared: a `_resolve` on another thread must + # not have entries vanish mid-lookup or memoize a stale answer into + # the live dict. + self.resolved = {} # What the host registers, at import and afterwards. -_HOST = _Registry() +_HOST = Registry() # The registry in effect. Unset outside a sandbox, where there is one registry # and it is the host's; `sandboxed_registrations` sets one for the sandbox. -_registry: contextvars.ContextVar[_Registry | None] = contextvars.ContextVar( +_registry: contextvars.ContextVar[Registry | None] = contextvars.ContextVar( "_registry", default=None ) -def _current() -> _Registry: +def _current() -> Registry: return _registry.get() or _HOST @@ -179,25 +182,23 @@ def _registration(class_id: str) -> _Registration | None: @contextlib.contextmanager -def sandboxed_registrations() -> Iterator[None]: - """Give this context a registry of its own, holding only the built-ins. - - Entered by `workflow_sandbox`. The sandbox re-imports the workflow's - module, so the `@serializable` classes it needs are registered again here; - what the host registered elsewhere is deliberately not visible, which is - what keeps the sandbox from being handed a class its own code never - imported -- and through that class's `__globals__`, the host's module - graph. - - Nothing shared is mutated, so there is nothing to restore and concurrent - runs do not interfere. The registry is dropped whole when the scope closes, - taking the sandbox's classes with it, which is why none of this needs weak - keys: no payload of this run crosses the boundary un-serialized. +def sandboxed_registrations(sandboxed: Registry) -> Iterator[None]: + """Give this context *sandboxed*'s registrations instead of the host's. + + Entered by `Sandbox.enter`. The sandbox imports the workflow's module + itself, so the `@serializable` classes it needs are registered here; what + the host registered elsewhere is deliberately not visible, which is what + keeps the sandbox from being handed a class its own code never imported -- + and through that class's `__globals__`, the host's module graph. + + The registry is the caller's, seeded with the built-ins at `Sandbox` + construction rather than here: a sandbox can outlive one run, its modules + -- and so its classes and their registrations -- persisting from run to + run, and re-adding the built-ins on every entry would churn state + concurrent runs are reading. """ - sandboxed = _Registry() token = _registry.set(sandboxed) try: - _register_builtins(sandboxed) yield finally: _registry.reset(token) @@ -212,8 +213,13 @@ def _resolve(tp: type) -> _Registration | None: needing an ordering rule of its own. """ registry = _current() - if tp in registry.resolved: - class_id = registry.resolved[tp] + # Snapshot the resolved table, because add might replace it with a + # new one on a change and we want to make sure we write into the + # stale old version instead of writing something stale to the main + # table. + resolved = registry.resolved + if tp in resolved: + class_id = resolved[tp] return registry.registration(class_id) if class_id is not None else None found: str | None = None for base in tp.__mro__: @@ -222,7 +228,7 @@ def _resolve(tp: type) -> _Registration | None: found = registry.by_class.get(base) if found is not None: break - registry.resolved[tp] = found + resolved[tp] = found return registry.registration(found) if found is not None else None @@ -310,7 +316,7 @@ def registration_hint(value: Any) -> str: # `_register_builtins` gives. -def _register_builtins(registry: _Registry) -> None: +def _register_builtins(registry: Registry) -> None: """Register the stdlib types this module carries into *registry*. Called for the host at import, and again for each sandbox. Each needs its From bbdb181896eb235da65741a56a6462156dcbb36a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 20 Aug 2026 11:22:44 -0700 Subject: [PATCH 2/2] kwonly --- src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py index 40c94bce..cb909956 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py +++ b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py @@ -921,7 +921,7 @@ def clear_pydantic_generics_cache(context: SandboxCleanupContext) -> None: ) -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class SandboxPolicy: """Configuration for the workflow sandbox, passed to ``Workflows``.