From 9c3673af345cf6c8fbe9100305897eebc0fe36a7 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:46:29 +0100 Subject: [PATCH] fix(store): avoid mutating published injected layer lists --- backend/services/ai_intel_store.py | 11 ++-- .../test_ai_intel_store_copy_on_write.py | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_ai_intel_store_copy_on_write.py diff --git a/backend/services/ai_intel_store.py b/backend/services/ai_intel_store.py index 48c19f5..c7fda01 100644 --- a/backend/services/ai_intel_store.py +++ b/backend/services/ai_intel_store.py @@ -137,15 +137,16 @@ def inject_layer_data( tagged.append(entry) with _data_lock: - existing = latest_data.get(layer) - if not isinstance(existing, list): - existing = [] + current = latest_data.get(layer) + existing = list(current) if isinstance(current, list) else [] if mode == "replace": existing = [e for e in existing if not e.get("_injected")] - existing.extend(tagged) - latest_data[layer] = existing + # Readers can hold references to published layer lists after releasing + # _data_lock. Build a fresh list and swap it atomically rather than + # mutating the published object in place with list.extend(). + latest_data[layer] = [*existing, *tagged] bump_data_version() diff --git a/backend/tests/test_ai_intel_store_copy_on_write.py b/backend/tests/test_ai_intel_store_copy_on_write.py new file mode 100644 index 0000000..8c828e6 --- /dev/null +++ b/backend/tests/test_ai_intel_store_copy_on_write.py @@ -0,0 +1,59 @@ +"""Regression coverage for copy-on-write OpenClaw layer injection.""" + +from services import ai_intel_store +from services.fetchers import _store + + +def _publish_test_layer(monkeypatch, items): + published = list(items) + monkeypatch.setitem(_store.latest_data, "air_quality", published) + monkeypatch.setattr(_store, "bump_data_version", lambda: None) + return published + + +def test_append_does_not_mutate_previously_published_list(monkeypatch): + before = _publish_test_layer(monkeypatch, [{"id": "existing"}]) + + result = ai_intel_store.inject_layer_data( + "air_quality", + [{"id": "injected"}], + mode="append", + ) + + after = _store.latest_data["air_quality"] + assert result == { + "ok": True, + "layer": "air_quality", + "injected": 1, + "mode": "append", + } + assert after is not before + assert before == [{"id": "existing"}] + assert [item["id"] for item in after] == ["existing", "injected"] + assert after[-1]["_injected"] is True + assert after[-1]["_source"] == "user:openclaw" + + +def test_replace_does_not_mutate_previously_published_list(monkeypatch): + before = _publish_test_layer( + monkeypatch, + [ + {"id": "native"}, + {"id": "old-injected", "_injected": True}, + ], + ) + + result = ai_intel_store.inject_layer_data( + "air_quality", + [{"id": "new-injected"}], + mode="replace", + ) + + after = _store.latest_data["air_quality"] + assert result["ok"] is True + assert after is not before + assert before == [ + {"id": "native"}, + {"id": "old-injected", "_injected": True}, + ] + assert [item["id"] for item in after] == ["native", "new-injected"]