Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions docs/RELEASE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
7 changes: 5 additions & 2 deletions tests/test_documentation_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
132 changes: 67 additions & 65 deletions tests/test_runtime_bundle_rwlock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)]
Expand All @@ -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):
Expand Down Expand Up @@ -207,31 +217,28 @@ 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
reader_futures = [executor.submit(reader) for _ in range(10)]
# 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
Expand All @@ -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 = []
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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 = []
Expand All @@ -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
Expand Down