From dfe2cfb3925c06daee5f9f4cf40f964a51355d4b Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 16 Jun 2026 18:05:17 +0900 Subject: [PATCH 1/4] fix: ensure Worker subclasses are wrapped only once --- .../durable-object-inheritance.wd-test | 34 +++++++++ .../durable-object-inheritance/pyproject.toml | 5 ++ .../durable-object-inheritance/worker.py | 76 +++++++++++++++++++ .../durable-object-inheritance/wrangler.jsonc | 5 ++ packages/runtime-sdk/src/workers/_workers.py | 23 ++++-- 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test create mode 100644 packages/cli/tests/workerd-test/durable-object-inheritance/pyproject.toml create mode 100644 packages/cli/tests/workerd-test/durable-object-inheritance/worker.py create mode 100644 packages/cli/tests/workerd-test/durable-object-inheritance/wrangler.jsonc diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test b/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test new file mode 100644 index 0000000..4a3ccea --- /dev/null +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test @@ -0,0 +1,34 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .mainWorker), + (name = "TEST_TMPDIR", disk = (writable = true)), + ], +); + +const mainWorker :Workerd.Worker = ( + modules = [ + (name = "worker.py", pythonModule = embed "worker.py"), + %PYTHON_MODULES + ], + durableObjectNamespaces = [ + ( + className = "LeafDurableObject", + uniqueKey = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + enableSql = true, + ), + ( + className = "LeafDurableObjectWithInit", + uniqueKey = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", + enableSql = true, + ), + ], + durableObjectStorage = (localDisk = "TEST_TMPDIR"), + bindings = [ + (name = "DO_LEAF", durableObjectNamespace = "LeafDurableObject"), + (name = "DO_LEAF_INIT", durableObjectNamespace = "LeafDurableObjectWithInit"), + ], + compatibilityDate = "%COMPAT_DATE", + compatibilityFlags = ["python_workers", "service_binding_extra_handlers", "enable_python_external_sdk"], +); diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/pyproject.toml b/packages/cli/tests/workerd-test/durable-object-inheritance/pyproject.toml new file mode 100644 index 0000000..072f326 --- /dev/null +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "test" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [] diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py new file mode 100644 index 0000000..b6439cc --- /dev/null +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py @@ -0,0 +1,76 @@ +# Regression test: multi-level inheritance from DurableObject / WorkerEntrypoint. +# https://github.com/cloudflare/workers-py/issues/125 +# _wrap_subclass must not double-wrap ctx and env when the hierarchy is >1 deep. + +from workers import DurableObject, WorkerEntrypoint +from workers._workers import _EnvWrapper + + +class BaseDurableObject(DurableObject): + def __init__(self, ctx, env): + super().__init__(ctx, env) + # Fails if ctx is double-wrapped: DurableObjectContext(DurableObjectContext(...)) + self.ctx.storage.sql.exec("SELECT NULL") + + async def shared_method(self): + return "from base" + + +class LeafDurableObject(BaseDurableObject): + async def hello(self): + return "hello from leaf" + + async def check_env(self): + return self.env is not None + + async def check_ctx(self): + return self.ctx is not None + + async def check_storage(self): + self.ctx.storage.sql.exec("SELECT NULL") + return True + + +class LeafDurableObjectWithInit(BaseDurableObject): + def __init__(self, ctx, env): + super().__init__(ctx, env) + self.custom_attr = "custom" + + async def hello(self): + return "hello with init" + + async def check_custom(self): + return self.custom_attr == "custom" + + async def check_storage(self): + self.ctx.storage.sql.exec("SELECT NULL") + return True + + +class BaseEntrypoint(WorkerEntrypoint): + def get_name(self): + return "base" + + +class Default(BaseEntrypoint): + async def test(self, ctrl): + id1 = self.env.DO_LEAF.idFromName("leaf-test") + obj1 = self.env.DO_LEAF.get(id1) + assert await obj1.hello() == "hello from leaf" + assert await obj1.shared_method() == "from base" + assert await obj1.check_env() + assert await obj1.check_ctx() + assert await obj1.check_storage() + + id2 = self.env.DO_LEAF_INIT.idFromName("leaf-init-test") + obj2 = self.env.DO_LEAF_INIT.get(id2) + assert await obj2.hello() == "hello with init" + assert await obj2.check_custom() + assert await obj2.check_storage() + + assert self.get_name() == "base" + assert self.env is not None + + # env must be wrapped exactly once: _EnvWrapper(js_env), not _EnvWrapper(_EnvWrapper(js_env)) + assert isinstance(self.env, _EnvWrapper) + assert not isinstance(self.env._env, _EnvWrapper) diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/wrangler.jsonc b/packages/cli/tests/workerd-test/durable-object-inheritance/wrangler.jsonc new file mode 100644 index 0000000..f89cde9 --- /dev/null +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "test-worker", + "compatibility_date": "%COMPAT_DATE", + "compatibility_flags": ["python_workers"] +} diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 7a6d007..26a4af6 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1475,18 +1475,27 @@ async def _closure(): return result +_INIT_WRAPPED = "__workers_init_wrapped__" + + def _wrap_subclass(cls): # Override the class __init__ so that we can wrap the `env` in the constructor. original_init = cls.__init__ def wrapped_init(self, *args, **kwargs): - args = list(args) - if len(args) > 0: - _pyodide_entrypoint_helper.patchWaitUntil(args[0]) - if issubclass(cls, DurableObject): - args[0] = DurableObjectContext(args[0]) - if len(args) > 1: - args[1] = _EnvWrapper(args[1]) + # Guard against double-wrapping in multi-level inheritance. + # __init_subclass__ fires for every subclass, so each level installs + # its own wrapped_init. The per-instance flag ensures ctx/env are + # wrapped only once, by the outermost wrapped_init. + if not hasattr(self, _INIT_WRAPPED): + args = list(args) + if len(args) > 0: + _pyodide_entrypoint_helper.patchWaitUntil(args[0]) + if issubclass(cls, DurableObject): + args[0] = DurableObjectContext(args[0]) + if len(args) > 1: + args[1] = _EnvWrapper(args[1]) + setattr(self, _INIT_WRAPPED, True) original_init(self, *args, **kwargs) From 93c40ab73ee6cd5a73f71dde6fcac777e1bc4c8b Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 17 Jun 2026 00:46:13 +0900 Subject: [PATCH 2/4] chore: check base class --- packages/runtime-sdk/src/workers/_workers.py | 34 +++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 26a4af6..5e3d528 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1475,27 +1475,18 @@ async def _closure(): return result -_INIT_WRAPPED = "__workers_init_wrapped__" - - def _wrap_subclass(cls): # Override the class __init__ so that we can wrap the `env` in the constructor. original_init = cls.__init__ def wrapped_init(self, *args, **kwargs): - # Guard against double-wrapping in multi-level inheritance. - # __init_subclass__ fires for every subclass, so each level installs - # its own wrapped_init. The per-instance flag ensures ctx/env are - # wrapped only once, by the outermost wrapped_init. - if not hasattr(self, _INIT_WRAPPED): - args = list(args) - if len(args) > 0: - _pyodide_entrypoint_helper.patchWaitUntil(args[0]) - if issubclass(cls, DurableObject): - args[0] = DurableObjectContext(args[0]) - if len(args) > 1: - args[1] = _EnvWrapper(args[1]) - setattr(self, _INIT_WRAPPED, True) + args = list(args) + if len(args) > 0: + _pyodide_entrypoint_helper.patchWaitUntil(args[0]) + if issubclass(cls, DurableObject): + args[0] = DurableObjectContext(args[0]) + if len(args) > 1: + args[1] = _EnvWrapper(args[1]) original_init(self, *args, **kwargs) @@ -1542,7 +1533,8 @@ def __init__(self, ctx: "DurableObjectState", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs): - _wrap_subclass(cls) + if DurableObject in cls.__bases__: + _wrap_subclass(cls) class WorkerEntrypoint: @@ -1558,7 +1550,10 @@ def __init__(self, ctx: "ExecutionContext", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs: Any): - _wrap_subclass(cls) + # Make sure we do not apply the wrapper multiple times + # when inheriting from the base class + if WorkerEntrypoint in cls.__bases__: + _wrap_subclass(cls) class WorkflowEntrypoint: @@ -1574,5 +1569,6 @@ def __init__(self, ctx: "ExecutionContext", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs: Any): - _wrap_subclass(cls) + if WorkflowEntrypoint in cls.__bases__: + _wrap_subclass(cls) _wrap_workflow_step(cls) From 66c2dd7e6e711904e95e4d11ad851fbee2866e10 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 17 Jun 2026 00:50:19 +0900 Subject: [PATCH 3/4] chore: tidy up test --- .../durable-object-inheritance/worker.py | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py index b6439cc..2562dee 100644 --- a/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py @@ -3,13 +3,28 @@ # _wrap_subclass must not double-wrap ctx and env when the hierarchy is >1 deep. from workers import DurableObject, WorkerEntrypoint -from workers._workers import _EnvWrapper +from workers._workers import DurableObjectContext, _EnvWrapper + + +def assert_wrapped_once(obj): + assert isinstance(obj.env, _EnvWrapper), "env should be an _EnvWrapper" + assert not isinstance(obj.env._env, _EnvWrapper), "env should not be double-wrapped" + + +def assert_do_wrapped_once(obj): + assert_wrapped_once(obj) + assert isinstance(obj.ctx, DurableObjectContext), ( + "ctx should be a DurableObjectContext" + ) + assert not isinstance(obj.ctx._ctx, DurableObjectContext), ( + "ctx should not be double-wrapped" + ) class BaseDurableObject(DurableObject): def __init__(self, ctx, env): super().__init__(ctx, env) - # Fails if ctx is double-wrapped: DurableObjectContext(DurableObjectContext(...)) + assert_do_wrapped_once(self) self.ctx.storage.sql.exec("SELECT NULL") async def shared_method(self): @@ -20,13 +35,8 @@ class LeafDurableObject(BaseDurableObject): async def hello(self): return "hello from leaf" - async def check_env(self): - return self.env is not None - - async def check_ctx(self): - return self.ctx is not None - - async def check_storage(self): + async def verify_wrapping(self): + assert_do_wrapped_once(self) self.ctx.storage.sql.exec("SELECT NULL") return True @@ -34,15 +44,15 @@ async def check_storage(self): class LeafDurableObjectWithInit(BaseDurableObject): def __init__(self, ctx, env): super().__init__(ctx, env) + assert_do_wrapped_once(self) self.custom_attr = "custom" async def hello(self): return "hello with init" - async def check_custom(self): - return self.custom_attr == "custom" - - async def check_storage(self): + async def verify_wrapping(self): + assert_do_wrapped_once(self) + assert self.custom_attr == "custom" self.ctx.storage.sql.exec("SELECT NULL") return True @@ -54,23 +64,16 @@ def get_name(self): class Default(BaseEntrypoint): async def test(self, ctrl): + assert_wrapped_once(self) + assert self.get_name() == "base" + id1 = self.env.DO_LEAF.idFromName("leaf-test") obj1 = self.env.DO_LEAF.get(id1) assert await obj1.hello() == "hello from leaf" assert await obj1.shared_method() == "from base" - assert await obj1.check_env() - assert await obj1.check_ctx() - assert await obj1.check_storage() + assert await obj1.verify_wrapping() id2 = self.env.DO_LEAF_INIT.idFromName("leaf-init-test") obj2 = self.env.DO_LEAF_INIT.get(id2) assert await obj2.hello() == "hello with init" - assert await obj2.check_custom() - assert await obj2.check_storage() - - assert self.get_name() == "base" - assert self.env is not None - - # env must be wrapped exactly once: _EnvWrapper(js_env), not _EnvWrapper(_EnvWrapper(js_env)) - assert isinstance(self.env, _EnvWrapper) - assert not isinstance(self.env._env, _EnvWrapper) + assert await obj2.verify_wrapping() From 7da9c0ff48725e89402a7372651f3e8ff086ca81 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 17 Jun 2026 13:39:26 +0900 Subject: [PATCH 4/4] chore: tidy up subclass check --- .../durable-object-inheritance.wd-test | 6 ++++ .../durable-object-inheritance/worker.py | 24 ++++++++++++++-- packages/runtime-sdk/src/workers/_workers.py | 28 +++++++++++++++---- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test b/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test index 4a3ccea..46106f6 100644 --- a/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/durable-object-inheritance.wd-test @@ -23,11 +23,17 @@ const mainWorker :Workerd.Worker = ( uniqueKey = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", enableSql = true, ), + ( + className = "RedundantBaseDO", + uniqueKey = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", + enableSql = true, + ), ], durableObjectStorage = (localDisk = "TEST_TMPDIR"), bindings = [ (name = "DO_LEAF", durableObjectNamespace = "LeafDurableObject"), (name = "DO_LEAF_INIT", durableObjectNamespace = "LeafDurableObjectWithInit"), + (name = "DO_REDUNDANT", durableObjectNamespace = "RedundantBaseDO"), ], compatibilityDate = "%COMPAT_DATE", compatibilityFlags = ["python_workers", "service_binding_extra_handlers", "enable_python_external_sdk"], diff --git a/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py index 2562dee..43f9523 100644 --- a/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py +++ b/packages/cli/tests/workerd-test/durable-object-inheritance/worker.py @@ -57,15 +57,30 @@ async def verify_wrapping(self): return True +class RedundantBaseDO(BaseDurableObject, DurableObject): + async def hello(self): + return "hello from redundant" + + async def verify_wrapping(self): + assert_do_wrapped_once(self) + self.ctx.storage.sql.exec("SELECT NULL") + return True + + class BaseEntrypoint(WorkerEntrypoint): def get_name(self): return "base" -class Default(BaseEntrypoint): +class RedundantBaseEntrypoint(BaseEntrypoint, WorkerEntrypoint): + def get_name(self): + return "redundant" + + +class Default(RedundantBaseEntrypoint): async def test(self, ctrl): assert_wrapped_once(self) - assert self.get_name() == "base" + assert self.get_name() == "redundant" id1 = self.env.DO_LEAF.idFromName("leaf-test") obj1 = self.env.DO_LEAF.get(id1) @@ -77,3 +92,8 @@ async def test(self, ctrl): obj2 = self.env.DO_LEAF_INIT.get(id2) assert await obj2.hello() == "hello with init" assert await obj2.verify_wrapping() + + id3 = self.env.DO_REDUNDANT.idFromName("redundant-test") + obj3 = self.env.DO_REDUNDANT.get(id3) + assert await obj3.hello() == "hello from redundant" + assert await obj3.verify_wrapping() diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 5e3d528..987db8b 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1475,6 +1475,26 @@ async def _closure(): return result +def _is_direct_binding_subclass(cls: type, binding_cls: type) -> bool: + """ + Checks if the class is a direct subclass of the binding class. + + In order to prevent applying the wrapper multiple times, + we only want to apply the wrapper if the class is directly inheriting + from the binding class, not if it's inheriting from another class that + inherits from the binding class. + + Examples: + - `class A(DurableObject)` -> True + - `class B(A)` -> False + - `class C(B)` -> False + - `class D(C, DurableObject)` -> False + """ + return not any( + issubclass(b, binding_cls) for b in cls.__bases__ if b is not binding_cls + ) + + def _wrap_subclass(cls): # Override the class __init__ so that we can wrap the `env` in the constructor. original_init = cls.__init__ @@ -1533,7 +1553,7 @@ def __init__(self, ctx: "DurableObjectState", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs): - if DurableObject in cls.__bases__: + if _is_direct_binding_subclass(cls, DurableObject): _wrap_subclass(cls) @@ -1550,9 +1570,7 @@ def __init__(self, ctx: "ExecutionContext", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs: Any): - # Make sure we do not apply the wrapper multiple times - # when inheriting from the base class - if WorkerEntrypoint in cls.__bases__: + if _is_direct_binding_subclass(cls, WorkerEntrypoint): _wrap_subclass(cls) @@ -1569,6 +1587,6 @@ def __init__(self, ctx: "ExecutionContext", env: "Env"): self.env = env def __init_subclass__(cls, **_kwargs: Any): - if WorkflowEntrypoint in cls.__bases__: + if _is_direct_binding_subclass(cls, WorkflowEntrypoint): _wrap_subclass(cls) _wrap_workflow_step(cls)