From 26e1e7ef4ce350d02424ca1ca4a8c554c85c8e5f Mon Sep 17 00:00:00 2001 From: Ahinoam Pollack Date: Wed, 28 Oct 2026 15:31:37 +0100 Subject: [PATCH] fix(revisions): publish a delta for a collected model instead of raising push_delta looked up the instance and dereferenced it unconditionally. The registry holds weak references, so a delta flushed after its model was collected resolved to None and the flush raised AttributeError. Publish it without a revision; get_state already answers {"id": ..., "state": None} for such an id. Co-Authored-By: Claude Opus 5.5 --- src/trame_dataclass/module/revisions.py | 11 ++++++++--- tests/test_protocol_revisions.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/trame_dataclass/module/revisions.py b/src/trame_dataclass/module/revisions.py index ec47c4c..a5ab8d9 100644 --- a/src/trame_dataclass/module/revisions.py +++ b/src/trame_dataclass/module/revisions.py @@ -48,9 +48,14 @@ def snapshot(self, obj): def push_delta(self, msg): """Publish a state delta to all connected clients via the ``trame.dataclass.publish`` topic.""" - # Only a live model flushes, and every model registers itself on creation, so - # its id always resolves and every publication carries a revision. - key = self._track_revisions(self.find_instance(msg["id"])) + obj = self.find_instance(msg["id"]) + if obj is None: + # The registry holds weak references, so a delta flushed after its model + # was collected has no instance. Publish it without a revision, as + # get_state already answers {"id": ..., "state": None} for such an id. + self.publish("trame.dataclass.publish", msg) + return + key = self._track_revisions(obj) revision = self.state_revisions[key] + 1 self.state_revisions[key] = revision self.publish("trame.dataclass.publish", {**deepcopy(msg), "revision": revision}) diff --git a/tests/test_protocol_revisions.py b/tests/test_protocol_revisions.py index 5954a5a..bff4ae7 100644 --- a/tests/test_protocol_revisions.py +++ b/tests/test_protocol_revisions.py @@ -143,6 +143,18 @@ def test_a_retired_id_keeps_the_existing_missing_state_protocol(version): assert p.get_state("gone") == {"id": "gone", "state": None} +def test_a_delta_for_a_collected_model_is_published_without_a_revision(version): + """A delta flushed after its model was collected must not raise.""" + p, a = protocol(version), version.Sample() + retired = a._id + del a + gc.collect() + p.push_delta({"id": retired, "state": {"value": 1}}) + published = p.publish.call_args.args[1] + assert published == {"id": retired, "state": {"value": 1}} + assert "revision" not in published + + def test_a_model_that_compares_by_value_can_still_be_read_and_revised(version): """MUTATION (run): key ``state_revisions`` by ``obj``. Both calls raise TypeError.""" p, a = protocol(version), version.ValueEqual()