From c37d9c58636b993bf55f53566a989bf5d8371c6a Mon Sep 17 00:00:00 2001 From: Ervins Strauhmanis Date: Fri, 15 May 2026 18:44:31 +0300 Subject: [PATCH] release: fix freethreaded gate and retag recovery --- CHANGELOG.md | 4 +- docs/RELEASE_PROTOCOL.md | 13 +-- tests/test_documentation_tooling.py | 7 +- tests/test_runtime_bundle_rwlock.py | 132 ++++++++++++++-------------- 4 files changed, 83 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6bf06d..7fa18a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release publication now enforces annotated tags without depending on a separately configured GitHub-verified tag signature.** The publish workflow and release runbook now agree that `vX.Y.Z` must be an immutable annotated tag object, and the runbook includes the one allowed recovery path for replacing an accidentally - pushed lightweight tag before any public release object or assets exist. + pushed lightweight tag before any public release object or assets exist. When a failed + pre-publication publish attempt reveals a release-blocking workflow or protocol defect, the + runbook now explicitly reopens Step 5 on the corrective merge commit before the tag is recreated. - **Release-tag immutability checks now live with the release architecture contract instead of the Python support owner.** The repository validator that owns supported-interpreter truth now limits itself to Python matrix wiring, while the publish workflow's annotated-tag requirements are enforced by the diff --git a/docs/RELEASE_PROTOCOL.md b/docs/RELEASE_PROTOCOL.md index d9bdeef1..3d580ac4 100644 --- a/docs/RELEASE_PROTOCOL.md +++ b/docs/RELEASE_PROTOCOL.md @@ -341,9 +341,11 @@ the PyPI job fails — repair the workflow on `main`, merge the fix, and then re `workflow_dispatch` for the existing tag. Do not delete, move, or recreate the tag to retrigger publication. -If the publish workflow fails immediately because the tag is the wrong object type and GitHub -Release assets were never created, fix the contract first, then replace the tag exactly once with -an annotated tag on the intended release commit: +If the publish workflow fails before any public release object or assets exist because the tag is +the wrong object type, or because the publish contract itself needed correction, treat publication +as not yet started. Fix the defect on a normal branch and PR first. If that fix changes `main`, +reopen Step 5 on the merged corrective commit and use that newly verified `main` commit as the +release target. Then replace the bad tag exactly once with an annotated tag: ```bash gh release view vX.Y.Z --json tagName,isDraft,isPrerelease,publishedAt,url,assets || true @@ -353,10 +355,11 @@ git tag -a vX.Y.Z -m "Release X.Y.Z" git push origin vX.Y.Z ``` -This recovery is allowed only when both of these are true: +This recovery is allowed only when all of these are true: - the failed publish run exited before any public release object or assets were created; -- the replacement tag points to the same intended release commit you already verified in Step 5. +- any release-blocking corrective fix has already been merged through the normal PR path; +- the replacement tag points to the intended release commit you most recently re-verified in Step 5. If GitHub Release assets need manual convergence after the workflow, use: diff --git a/tests/test_documentation_tooling.py b/tests/test_documentation_tooling.py index 95c42d89..887a2a9c 100644 --- a/tests/test_documentation_tooling.py +++ b/tests/test_documentation_tooling.py @@ -549,14 +549,17 @@ def test_release_protocol_artifact_leak_check_uses_base_tooling() -> None: assert "| rg " not in text -def test_release_protocol_requires_annotated_tags_and_documents_lightweight_recovery() -> None: - """Release instructions should require annotated tags and bound the wrong-tag recovery path.""" +def test_release_protocol_requires_annotated_tags_and_documents_prepublication_retag_recovery() -> None: + """Release instructions should bound the wrong-tag recovery path before any public release exists.""" text = (REPO_ROOT / "docs" / "RELEASE_PROTOCOL.md").read_text(encoding="utf-8") assert 'git tag -a vX.Y.Z -m "Release X.Y.Z"' in text assert "wrong object type" in text assert "git push --delete origin vX.Y.Z" in text assert "the failed publish run exited before any public release object or assets were created" in text + assert "any release-blocking corrective fix has already been merged through the normal PR path" in text + assert "the intended release commit you most recently re-verified in Step 5" in text + assert "the same intended release commit you already verified in Step 5" not in text def test_atheris_inventory_readme_matches_target_manifest() -> None: diff --git a/tests/test_runtime_bundle_rwlock.py b/tests/test_runtime_bundle_rwlock.py index f0c1b3ac..4f86f1d0 100644 --- a/tests/test_runtime_bundle_rwlock.py +++ b/tests/test_runtime_bundle_rwlock.py @@ -9,12 +9,33 @@ import threading import time +from collections import Counter from concurrent.futures import ThreadPoolExecutor +from queue import Empty, SimpleQueue from ftllexengine.runtime.bundle import FluentBundle from ftllexengine.runtime.cache_config import CacheConfig +# Premise: +# Free-threaded CPython no longer serializes unsynchronized test bookkeeping +# through the GIL, so worker threads must not mutate shared counters and +# result lists if we want the test outcome to reflect the bundle contract. +# +# Reason: +# These concurrency tests aggregate worker results in the owning thread or via +# a thread-safe queue so failures expose runtime locking defects instead of +# races in the test harness itself. +def _drain_simple_queue[T](queue: SimpleQueue[T]) -> list[T]: + """Return every queued item after producers have finished.""" + items: list[T] = [] + while True: + try: + items.append(queue.get_nowait()) + except Empty: + return items + + class TestBundleReadOperationsConcurrency: """Test that read operations allow concurrency.""" @@ -23,16 +44,12 @@ def test_concurrent_format_pattern(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("msg = Hello, { $name }!") - results = [] - - def format_message() -> None: - result, errors = bundle.format_pattern("msg", {"name": "World"}) - results.append((result, errors)) + def format_message() -> tuple[str, tuple[object, ...]]: + return bundle.format_pattern("msg", {"name": "World"}) with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(format_message) for _ in range(50)] - for future in futures: - future.result() + results = [future.result() for future in futures] assert len(results) == 50 for result, errors in results: @@ -44,17 +61,14 @@ def test_concurrent_has_message(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("msg1 = Hello") - results = [] - - def check_message() -> None: + def check_message() -> tuple[bool, bool]: has_msg1 = bundle.has_message("msg1") has_msg2 = bundle.has_message("msg2") - results.append((has_msg1, has_msg2)) + return (has_msg1, has_msg2) with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(check_message) for _ in range(30)] - for future in futures: - future.result() + results = [future.result() for future in futures] assert len(results) == 30 for has_msg1, has_msg2 in results: @@ -66,16 +80,13 @@ def test_concurrent_introspection(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("price = { NUMBER($amount, minimumFractionDigits: 2) }") - results = [] - - def introspect() -> None: + def introspect() -> frozenset[str]: info = bundle.introspect_message("price") - results.append(info.get_variable_names()) + return info.get_variable_names() with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(introspect) for _ in range(20)] - for future in futures: - future.result() + results = [future.result() for future in futures] assert len(results) == 20 for var_names in results: @@ -86,16 +97,13 @@ def test_concurrent_validate_resource(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("msg1 = Hello") - results = [] - - def validate() -> None: + def validate() -> bool: result = bundle.validate_resource("msg2 = World") - results.append(result.is_valid) + return result.is_valid with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(validate) for _ in range(20)] - for future in futures: - future.result() + results = [future.result() for future in futures] assert len(results) == 20 assert all(results) @@ -142,11 +150,11 @@ def test_concurrent_add_resource_serialized(self) -> None: """Multiple add_resource calls are serialized (exclusive write access).""" bundle = FluentBundle("en", use_isolating=False) - messages_added = [] + messages_added: SimpleQueue[int] = SimpleQueue() def add_message(msg_id: int) -> None: bundle.add_resource(f"msg{msg_id} = Message {msg_id}") - messages_added.append(msg_id) + messages_added.put(msg_id) time.sleep(0.01) # Simulate work threads = [threading.Thread(target=add_message, args=(i,)) for i in range(5)] @@ -155,9 +163,11 @@ def add_message(msg_id: int) -> None: for thread in threads: thread.join() + added_ids = _drain_simple_queue(messages_added) + # All messages should be added - assert len(messages_added) == 5 - assert set(messages_added) == {0, 1, 2, 3, 4} + assert len(added_ids) == 5 + assert set(added_ids) == {0, 1, 2, 3, 4} # Verify all messages exist for i in range(5): @@ -207,22 +217,19 @@ def test_many_readers_one_writer(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("msg = Hello, { $name }!") - read_count = 0 - write_count = 0 - - def reader() -> None: - nonlocal read_count + def reader() -> int: + local_reads = 0 for _ in range(10): result, _ = bundle.format_pattern("msg", {"name": "Test"}) assert result == "Hello, Test!" - read_count += 1 + local_reads += 1 time.sleep(0.001) + return local_reads - def writer() -> None: - nonlocal write_count + def writer() -> int: time.sleep(0.02) # Let readers start bundle.add_resource("msg2 = New message") - write_count += 1 + return 1 with ThreadPoolExecutor(max_workers=15) as executor: # Many readers @@ -230,8 +237,8 @@ def writer() -> None: # One writer writer_future = executor.submit(writer) - for future in [*reader_futures, writer_future]: - future.result() + read_count = sum(future.result() for future in reader_futures) + write_count = writer_future.result() assert read_count == 100 # 10 readers * 10 iterations assert write_count == 1 @@ -242,15 +249,13 @@ def test_interleaved_reads_writes(self) -> None: bundle = FluentBundle("en", use_isolating=False) bundle.add_resource("count = { $val }") - operations = [] - - def reader(reader_id: int) -> None: + def reader(reader_id: int) -> str: _result, _ = bundle.format_pattern("count", {"val": reader_id}) - operations.append(f"R{reader_id}") + return f"R{reader_id}" - def writer(msg_id: int) -> None: + def writer(msg_id: int) -> str: bundle.add_resource(f"msg{msg_id} = Message {msg_id}") - operations.append(f"W{msg_id}") + return f"W{msg_id}" with ThreadPoolExecutor(max_workers=20) as executor: futures = [] @@ -261,8 +266,7 @@ def writer(msg_id: int) -> None: for i in range(10, 20): futures.append(executor.submit(reader, i)) - for future in futures: - future.result() + operations = [future.result() for future in futures] # All operations completed assert len(operations) == 25 # 20 readers + 5 writers @@ -310,21 +314,19 @@ def test_cache_clear_synchronized(self) -> None: # Prime cache bundle.format_pattern("msg") - clear_count = 0 - format_count = 0 + clear_events: SimpleQueue[None] = SimpleQueue() + format_events: SimpleQueue[None] = SimpleQueue() def clear_cache() -> None: - nonlocal clear_count for _ in range(5): bundle.clear_cache() - clear_count += 1 + clear_events.put(None) time.sleep(0.002) def format_message() -> None: - nonlocal format_count for _ in range(10): bundle.format_pattern("msg") - format_count += 1 + format_events.put(None) time.sleep(0.001) clear_thread = threading.Thread(target=clear_cache) @@ -336,6 +338,9 @@ def format_message() -> None: clear_thread.join() format_thread.join() + clear_count = len(_drain_simple_queue(clear_events)) + format_count = len(_drain_simple_queue(format_events)) + assert clear_count == 5 assert format_count == 10 @@ -392,20 +397,18 @@ def test_high_concurrency_mixed_operations(self) -> None: bundle = FluentBundle("en", use_isolating=False, cache=CacheConfig()) bundle.add_resource("msg = Hello, { $name }!") - operation_count = {"format": 0, "add": 0, "check": 0} - - def format_operation() -> None: + def format_operation() -> str: result, _ = bundle.format_pattern("msg", {"name": "Test"}) assert result == "Hello, Test!" - operation_count["format"] += 1 + return "format" - def add_operation(msg_id: int) -> None: + def add_operation(msg_id: int) -> str: bundle.add_resource(f"msg{msg_id} = Message {msg_id}") - operation_count["add"] += 1 + return "add" - def check_operation() -> None: - bundle.has_message("msg") - operation_count["check"] += 1 + def check_operation() -> str: + assert bundle.has_message("msg") is True + return "check" with ThreadPoolExecutor(max_workers=30) as executor: futures = [] @@ -419,8 +422,7 @@ def check_operation() -> None: for _ in range(50): futures.append(executor.submit(check_operation)) - for future in futures: - future.result() + operation_count = Counter(future.result() for future in futures) assert operation_count["format"] == 100 assert operation_count["add"] == 10