项目文件夹

文件
Ali Khokhar 4951983b5e Replace global runtime resources with explicit ownership (#1042)
## Problem

Provider, messaging, and transcription resources relied on
process-global state, leaving replacement, cancellation, and shutdown
ownership ambiguous. Separate server lifetimes could share
event-loop-bound resources or retain failed cleanup work.

## Changes

| Before | After |
| --- | --- |
| Provider clients found limiters through global singleton and scoped
registries. | Each provider instance receives and owns one explicitly
constructed limiter. |
| Messaging queues and voice pipelines relied on singleton or
module-global state. | Each platform owns its limiter and outbox, while
the application owns one injected transcriber. |
| Messaging shutdown mixed ingress, active work, delivery, and SDK
cleanup. | Application shutdown quiesces ingress, drains work, closes
delivery, then releases transcription and providers. |
| Cancelled or failed provider cleanup could be forgotten or treated as
complete. | The provider manager retains shielded generation and
unpublished-runtime cleanup until it succeeds. |
| Discord and Telegram startup tasks could outlive or poison runtime
readiness. | Platform runtimes observe long-lived tasks and retry only
independently repeatable lifecycle steps. |
| Constructor-captured security and diagnostic settings appeared
hot-applicable. | Admin marks those settings restart-required so applied
policy matches the running resource graph. |
| Lifecycle races lacked direct ownership coverage. | Deterministic
cancellation, retry, isolation, teardown, and live smoke contracts
protect the final ownership model. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves runtime resources from global state into explicitly owned
application objects. The main changes are:

- Provider generations own their rate limiters and cleanup tasks.
- Messaging platforms own their limiter, outbox, ingress, and delivery
lifecycle.
- Application shutdown now runs through ordered cleanup gates.
- Voice transcription is injected as an owned runtime resource.
- Admin config marks constructor-captured settings as restart-required.
</details>

<h3>Confidence Score: 4/5</h3>

The shutdown path needs a bounded cleanup result before merging.

Cleanup steps that hang never reach the retryable incomplete-shutdown
path. ASGI shutdown can remain stuck while waiting for an external SDK,
transcriber, workflow, or provider cleanup. The retry ownership model
works only after cleanup returns or raises.

src/free_claude_code/runtime/application.py

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex ran the requested verification, but its local artifact
references were not uploaded.
- The validation run completed successfully with EXIT\_CODE: 0 and 62
tests passed in 3.91 seconds, using the command uv run pytest -vv
tests/runtime/test\_application\_runtime.py
tests/runtime/test\_provider\_manager.py
tests/providers/test\_provider\_runtime.py.

<a
href="https://app.greptile.com/trex/runs/14064214/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/runtime/application.py | Refactors shutdown into
ordered retryable cleanup gates, but cleanup awaitables can still block
shutdown forever. |
| src/free_claude_code/runtime/asgi.py | Reports incomplete runtime
shutdown when `close()` returns false. |
| src/free_claude_code/runtime/provider_manager.py | Adds owned provider
cleanup retry state and shielded generation cleanup. |

</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22refactor%2Fruntime-owned-resources%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22refactor%2Fruntime-owned-resources%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fruntime%2Fapplication.py%3A59%0A**Cleanup%20Await%20Blocks%20Shutdown**%0A%0AWhen%20a%20platform%20SDK%20stop%2C%20workflow%20drain%2C%20transcriber%20close%2C%20or%20provider%20cleanup%20hangs%2C%20this%20helper%20waits%20forever%20and%20never%20returns%20%60False%60.%20ASGI%20shutdown%20stays%20stuck%20in%20%60runtime.close%28%29%60%20instead%20of%20reporting%20an%20incomplete%20shutdown%2C%20so%20the%20retained%20resource%20graph%20cannot%20be%20retried%20cleanly.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1042&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (2): Last reviewed commit: ["Report incomplete runtime
shutdown to
AS..."](https://github.com/alishahryar1/free-claude-code/commit/338b2bd179c3875b15bbd52818dd04c780e5d46d)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43454593)</sub>

> Greptile also left **1 inline comment** on this PR.

**Context used:**

- Context used - CLAUDE.md
([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8))

<!-- /greptile_comment -->
2026-07-10 18:46:50 -07:00

432 行
15 KiB
Python

import asyncio
import contextlib
import time
from collections.abc import Callable
import pytest
import pytest_asyncio
from free_claude_code.messaging.limiter import MessagingRateLimiter
class TestMessagingRateLimiter:
"""Tests for MessagingRateLimiter."""
@pytest_asyncio.fixture(autouse=True)
async def limiter_factory(self):
"""Build started limiters and stop every instance after the test."""
instances: list[MessagingRateLimiter] = []
def create(
*, rate_limit: int = 1, rate_window: float = 1.0
) -> MessagingRateLimiter:
limiter = MessagingRateLimiter(
rate_limit=rate_limit,
rate_window=rate_window,
)
limiter.start()
instances.append(limiter)
return limiter
self.create_limiter: Callable[..., MessagingRateLimiter] = create
yield
for limiter in reversed(instances):
await limiter.shutdown(timeout=0.1)
@pytest.mark.asyncio
async def test_instances_are_independent(self):
"""Each messaging runtime receives independent limiter state."""
limiter1 = self.create_limiter(rate_limit=1, rate_window=0.5)
limiter2 = self.create_limiter(rate_limit=99, rate_window=99.0)
assert limiter1 is not limiter2
assert limiter1.limiter._rate_limit == 1
assert limiter1.limiter._rate_window == 0.5
assert limiter2.limiter._rate_limit == 99
assert limiter2.limiter._rate_window == 99.0
await limiter1.shutdown(timeout=0.1)
async def succeed() -> str:
return "still running"
assert await limiter2.enqueue(succeed) == "still running"
@pytest.mark.asyncio
async def test_start_is_required_and_shutdown_is_idempotent(self):
limiter = MessagingRateLimiter(rate_limit=1, rate_window=1.0)
async def succeed() -> str:
return "ok"
with pytest.raises(RuntimeError, match="has not been started"):
await limiter.enqueue(succeed)
limiter.start()
limiter.start()
assert await limiter.enqueue(succeed) == "ok"
await limiter.shutdown(timeout=0.1)
await limiter.shutdown(timeout=0.1)
with pytest.raises(RuntimeError, match="is closed"):
await limiter.enqueue(succeed)
@pytest.mark.asyncio
async def test_compaction(self):
"""
Verify multiple rapid requests with same dedup_key are compacted.
Logic ported from verify_limiter.py
"""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
call_counts = {}
async def mock_edit(msg_id, content):
call_counts[msg_id] = call_counts.get(msg_id, 0) + 1
return f"done_{content}"
# Spam 5 edits
for i in range(5):
limiter.fire_and_forget(
lambda i=i: mock_edit("msg1", f"update_{i}"), dedup_key="edit:msg1"
)
# Wait for processing
# 1st might go through immediately, subsequent ones queue and compact
await asyncio.sleep(2.5)
# Expected: ~2 calls (first and last)
assert call_counts["msg1"] <= 2, (
f"Expected compaction to reduce calls, but got {call_counts.get('msg1', 0)}"
)
assert call_counts["msg1"] >= 1, "Expected at least one call"
@pytest.mark.asyncio
async def test_compaction_and_futures_resolution(self):
"""
Verify that even when compacted, all futures resolve to the result of the LAST execution.
Logic ported from verify_limiter_v2.py
"""
limiter = self.create_limiter(rate_limit=1, rate_window=0.5)
call_counts = {}
msg_id = "test_msg_hang"
async def mock_edit(mid, content):
call_counts[mid] = call_counts.get(mid, 0) + 1
await asyncio.sleep(0.05)
return f"result_{content}"
async def task(i):
return await limiter.enqueue(
lambda i=i: mock_edit(msg_id, f"v{i}"), dedup_key=f"edit:{msg_id}"
)
start_time = time.time()
# Enqueue 3 tasks concurrently
results = await asyncio.gather(task(1), task(2), task(3))
duration = time.time() - start_time
# All results should be the LAST one executed
for res in results:
assert res == "result_v3", f"Expected result_v3, got {res}"
# Should be reasonably fast
assert duration < 2.0, "Execution took too long"
# Calls should be compacted
assert call_counts[msg_id] <= 2, f"Too many actual calls: {call_counts[msg_id]}"
@pytest.mark.asyncio
async def test_flood_wait_handling(self):
"""Test that FloodWait exceptions pause the worker."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
# Mock exception with .seconds attribute
class FloodWait(Exception):
def __init__(self, seconds):
self.seconds = seconds
super().__init__(f"Flood wait {seconds}s")
call_count = 0
async def mock_fail():
nonlocal call_count
call_count += 1
raise FloodWait(1) # 1 second wait
async def mock_success():
nonlocal call_count
call_count += 1
return "success"
# First call fails and triggers pause
with contextlib.suppress(Exception):
await limiter.enqueue(mock_fail, dedup_key="key1")
assert limiter._paused_until > 0
# Enqueue success, it should wait
start = time.time()
await limiter.enqueue(mock_success, dedup_key="key2")
duration = time.time() - start
# Should have waited at least ~1s
assert duration >= 0.9, (
f"Should have waited for FloodWait, but took {duration:.2f}s"
)
assert call_count == 2
@pytest.mark.asyncio
async def test_flood_wait_retry_after_parsing(self):
"""Error message with 'retry after N' parses the wait seconds."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
async def mock_flood():
raise Exception("Flood wait: retry after 2 seconds")
with contextlib.suppress(Exception):
await limiter.enqueue(mock_flood, dedup_key="retry_parse")
# Should have parsed "after 2" -> 2 seconds
assert limiter._paused_until > 0
@pytest.mark.asyncio
async def test_non_flood_exception_no_pause(self):
"""Non-flood exception doesn't trigger pause."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
async def mock_error():
raise ValueError("some regular error")
with contextlib.suppress(ValueError):
await limiter.enqueue(mock_error, dedup_key="non_flood")
# Should NOT have paused since it's not a flood error
assert limiter._paused_until == 0
@pytest.mark.asyncio
async def test_flood_with_seconds_attribute(self):
"""Exception with .seconds attribute uses that value for pause."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
class FloodWaitCustom(Exception):
def __init__(self):
self.seconds = 2
super().__init__("Flood wait custom")
async def mock_flood():
raise FloodWaitCustom()
with contextlib.suppress(Exception):
await limiter.enqueue(mock_flood, dedup_key="flood_sec")
assert limiter._paused_until > 0
@pytest.mark.asyncio
async def test_proactive_strict_sliding_window(self):
"""
Proactive limiter should enforce a strict sliding window:
for any i, t[i+rate_limit] - t[i] >= rate_window (within tolerance).
"""
limiter = self.create_limiter(rate_limit=2, rate_window=0.5)
async def acquire(i: int) -> float:
async def _do() -> float:
return time.monotonic()
return await limiter.enqueue(_do, dedup_key=f"strict:{i}")
acquired = await asyncio.gather(*(acquire(i) for i in range(5)))
acquired.sort()
rate_limit = 2
rate_window = 0.5
tolerance = 0.05
for i in range(len(acquired) - rate_limit):
assert acquired[i + rate_limit] - acquired[i] >= rate_window - tolerance, (
f"Sliding window violated at i={i}: "
f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s"
)
@pytest.mark.asyncio
async def test_compaction_last_task_fails_all_futures_get_exception(self):
"""When compacted task's last func fails, all futures get the exception."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
async def ok_task():
return "ok"
async def fail_task():
raise RuntimeError("last task failed")
future1 = asyncio.create_task(limiter.enqueue(ok_task, dedup_key="fail_key"))
future2 = asyncio.create_task(limiter.enqueue(fail_task, dedup_key="fail_key"))
with pytest.raises(RuntimeError, match="last task failed"):
await future1
with pytest.raises(RuntimeError, match="last task failed"):
await future2
@pytest.mark.asyncio
async def test_fire_and_forget_failure_logged(self, caplog):
"""fire_and_forget with failing task logs error and does not re-raise."""
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
async def fail_task():
raise ValueError("fire_and_forget failed")
limiter.fire_and_forget(fail_task, dedup_key="fire_fail")
await asyncio.sleep(1.5)
joined = " ".join(str(r.message) for r in caplog.records)
assert "ValueError" in joined
assert "fire_and_forget failed" not in joined
@pytest.mark.asyncio
async def test_shutdown_settles_active_queued_and_background_work(self):
limiter = self.create_limiter(rate_limit=1, rate_window=60.0)
active_started = asyncio.Event()
never_finish = asyncio.Event()
async def active_operation() -> None:
active_started.set()
await never_finish.wait()
active = asyncio.create_task(
limiter.enqueue(active_operation, dedup_key="active")
)
await active_started.wait()
async def queued_operation() -> None:
await never_finish.wait()
queued = asyncio.create_task(
limiter.enqueue(queued_operation, dedup_key="queued")
)
limiter.fire_and_forget(queued_operation, dedup_key="background")
await asyncio.sleep(0)
await limiter.shutdown(timeout=0.1)
results = await asyncio.gather(active, queued, return_exceptions=True)
assert all(isinstance(result, asyncio.CancelledError) for result in results)
assert limiter._background_tasks == set()
assert limiter._queue_map == {}
assert not limiter._queue_list
@pytest.mark.asyncio
async def test_enqueue_cannot_enter_after_shutdown_begins(self):
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
await limiter._condition.acquire()
async def succeed() -> str:
return "unexpected"
enqueue_task = asyncio.create_task(
limiter.enqueue(succeed, dedup_key="shutdown-race")
)
await asyncio.sleep(0)
shutdown_task = asyncio.create_task(limiter.shutdown(timeout=0.1))
await asyncio.sleep(0)
assert limiter._closed is True
limiter._condition.release()
await shutdown_task
with pytest.raises(RuntimeError, match="is closed"):
await enqueue_task
@pytest.mark.asyncio
async def test_shutdown_preserves_external_cancellation(self):
limiter = self.create_limiter(rate_limit=1, rate_window=1.0)
release = asyncio.Event()
async def cancellation_resistant_worker() -> None:
try:
await release.wait()
except asyncio.CancelledError:
await release.wait()
worker_task = limiter._worker_task
assert worker_task is not None
await asyncio.sleep(0)
worker_task.cancel()
await asyncio.gather(worker_task, return_exceptions=True)
limiter._worker_task = asyncio.create_task(cancellation_resistant_worker())
shutdown_task = asyncio.create_task(limiter.shutdown(timeout=1.0))
await asyncio.sleep(0)
shutdown_task.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await shutdown_task
@pytest.mark.asyncio
async def test_cancelled_shutdown_retries_queued_future_settlement(self):
limiter = self.create_limiter(rate_limit=1, rate_window=60.0)
active_started = asyncio.Event()
never_finish = asyncio.Event()
async def active_operation() -> None:
active_started.set()
await never_finish.wait()
active = asyncio.create_task(
limiter.enqueue(active_operation, dedup_key="active")
)
await active_started.wait()
async def queued_operation() -> None:
await never_finish.wait()
queued = asyncio.create_task(
limiter.enqueue(queued_operation, dedup_key="queued")
)
while "queued" not in limiter._queue_map:
await asyncio.sleep(0)
await limiter._condition.acquire()
shutdown_task = asyncio.create_task(limiter.shutdown())
await asyncio.sleep(0)
assert limiter._closed is True
shutdown_task.cancel()
with pytest.raises(asyncio.CancelledError):
await shutdown_task
limiter._condition.release()
await limiter.shutdown(timeout=0.1)
results = await asyncio.gather(active, queued, return_exceptions=True)
assert all(isinstance(result, asyncio.CancelledError) for result in results)
assert limiter._queue_map == {}
assert not limiter._queue_list
assert limiter._worker_task is None
@pytest.mark.asyncio
async def test_cancelled_operation_does_not_stop_owned_worker(self):
limiter = self.create_limiter(rate_limit=2, rate_window=1.0)
async def cancelled_operation() -> None:
raise asyncio.CancelledError
async def successful_operation() -> str:
return "delivered"
first = asyncio.create_task(
limiter.enqueue(cancelled_operation, dedup_key="cancelled")
)
second = asyncio.create_task(
limiter.enqueue(successful_operation, dedup_key="next")
)
results = await asyncio.gather(first, second, return_exceptions=True)
assert isinstance(results[0], asyncio.CancelledError)
assert results[1] == "delivered"
assert limiter._worker_task is not None
assert not limiter._worker_task.done()