项目文件夹

文件
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

540 行
18 KiB
Python

import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from telegram.error import NetworkError, RetryAfter, TelegramError
def _limiter_mock() -> MagicMock:
limiter = MagicMock()
limiter.start = MagicMock()
limiter.shutdown = AsyncMock()
return limiter
def _telegram_runtime(*args, limiter=None, transcriber=None, **kwargs):
from free_claude_code.messaging.platforms.telegram import TelegramRuntime
return TelegramRuntime(
*args,
limiter=limiter or _limiter_mock(),
transcriber=transcriber,
**kwargs,
)
def test_telegram_platform_init_raises_when_dependency_missing():
with (
patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", False
),
pytest.raises(ImportError),
):
_telegram_runtime(bot_token="x")
@pytest.mark.asyncio
async def test_telegram_platform_start_requires_token():
with (
patch.dict("os.environ", {}, clear=True),
patch("free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True),
):
platform = _telegram_runtime(bot_token=None)
with pytest.raises(ValueError):
await platform.start()
@pytest.mark.asyncio
async def test_telegram_platform_quiesce_and_close_without_application():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = None
platform._connected = True
await platform.quiesce()
await platform.close()
assert platform.is_connected is False
platform._limiter.shutdown.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_telegram_close_cleans_up_partially_initialized_application():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
platform._application.running = False
platform._application.updater.running = False
platform._application.updater.stop = AsyncMock()
platform._application.stop = AsyncMock()
platform._application.shutdown = AsyncMock()
platform.outbound.close = AsyncMock()
await platform.quiesce()
await platform.close()
platform._application.updater.stop.assert_not_awaited()
platform._application.stop.assert_not_awaited()
platform.outbound.close.assert_awaited_once_with()
platform._limiter.shutdown.assert_awaited_once_with()
platform._application.shutdown.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_telegram_two_phase_lifecycle_drains_before_delivery_close():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
order: list[str] = []
platform._application = MagicMock()
platform._application.running = True
platform._application.updater.running = True
platform._application.updater.stop = AsyncMock(
side_effect=lambda: order.append("updater.stop")
)
platform._application.stop = AsyncMock(
side_effect=lambda: order.append("application.stop")
)
platform.outbound.close = AsyncMock(
side_effect=lambda: order.append("outbound.close")
)
platform._limiter.shutdown = AsyncMock(
side_effect=lambda: order.append("limiter.shutdown")
)
platform._application.shutdown = AsyncMock(
side_effect=lambda: order.append("application.shutdown")
)
await platform.quiesce()
assert order == ["updater.stop", "application.stop"]
await platform.close()
assert order == [
"updater.stop",
"application.stop",
"outbound.close",
"limiter.shutdown",
"application.shutdown",
]
assert platform.is_connected is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failing_step",
["updater.stop", "application.stop"],
)
async def test_telegram_quiesce_attempts_all_steps_after_failure(failing_step):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
order: list[str] = []
async def record(step: str) -> None:
order.append(step)
if step == failing_step:
raise RuntimeError(step)
def action(step: str):
async def run() -> None:
await record(step)
return run
platform._application = MagicMock()
platform._application.running = True
platform._application.updater.running = True
platform._application.updater.stop = AsyncMock(
side_effect=action("updater.stop")
)
platform._application.stop = AsyncMock(side_effect=action("application.stop"))
platform.outbound.close = AsyncMock(side_effect=action("outbound.close"))
platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown"))
platform._application.shutdown = AsyncMock(
side_effect=action("application.shutdown")
)
with pytest.raises(RuntimeError, match=failing_step):
await platform.quiesce()
assert order == ["updater.stop", "application.stop"]
assert platform.is_connected is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failing_step",
["outbound.close", "limiter.shutdown", "application.shutdown"],
)
async def test_telegram_close_attempts_all_steps_after_failure(failing_step):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
order: list[str] = []
async def record(step: str) -> None:
order.append(step)
if step == failing_step:
raise RuntimeError(step)
def action(step: str):
async def run() -> None:
await record(step)
return run
platform._application = MagicMock()
platform._application.shutdown = AsyncMock(
side_effect=action("application.shutdown")
)
platform.outbound.close = AsyncMock(side_effect=action("outbound.close"))
platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown"))
with pytest.raises(RuntimeError, match=failing_step):
await platform.close()
assert order == [
"outbound.close",
"limiter.shutdown",
"application.shutdown",
]
@pytest.mark.asyncio
async def test_with_retry_returns_none_when_message_not_modified_network_error():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
async def _f():
raise NetworkError("Message is not modified")
assert await platform.outbound._with_retry(_f) is None
@pytest.mark.asyncio
async def test_with_retry_retries_network_error_then_succeeds(monkeypatch):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
calls = {"n": 0}
async def _f():
calls["n"] += 1
if calls["n"] == 1:
raise NetworkError("temporary")
return "ok"
assert await platform.outbound._with_retry(_f) == "ok"
assert calls["n"] == 2
@pytest.mark.asyncio
async def test_with_retry_honors_retry_after_timedelta(monkeypatch):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
calls = {"n": 0}
async def _f():
calls["n"] += 1
if calls["n"] == 1:
raise RetryAfter(retry_after=timedelta(seconds=0.01))
return "ok"
assert await platform.outbound._with_retry(_f) == "ok"
assert calls["n"] == 2
@pytest.mark.asyncio
async def test_with_retry_drops_parse_mode_on_markdown_entity_error():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
calls = []
async def _f(parse_mode=None):
calls.append(parse_mode)
if len(calls) == 1:
raise TelegramError("Can't parse entities: bad markdown")
return "ok"
assert await platform.outbound._with_retry(_f, parse_mode="MarkdownV2") == "ok"
assert calls == ["MarkdownV2", None]
@pytest.mark.asyncio
async def test_with_retry_can_raise_known_message_errors_for_bulk_fallback():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
async def _f():
raise TelegramError("message can't be deleted")
with pytest.raises(TelegramError):
await platform.outbound._with_retry(
_f,
suppress_known_message_errors=False,
)
@pytest.mark.asyncio
async def test_queue_send_message_uses_required_limiter():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
mock_msg = MagicMock()
mock_msg.message_id = 1
platform._application.bot = AsyncMock()
platform._application.bot.send_message = AsyncMock(return_value=mock_msg)
async def enqueue(operation, dedup_key=None):
return await operation()
platform._limiter.enqueue = AsyncMock(side_effect=enqueue)
assert (
await platform.outbound.queue_send_message("c", "t", fire_and_forget=False)
== "1"
)
platform._limiter.enqueue.assert_awaited_once()
platform._application.bot.send_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_queue_edit_message_uses_required_limiter():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
platform._application.bot = AsyncMock()
platform._application.bot.edit_message_text = AsyncMock()
async def enqueue(operation, dedup_key=None):
return await operation()
platform._limiter.enqueue = AsyncMock(side_effect=enqueue)
await platform.outbound.queue_edit_message("c", "1", "t", fire_and_forget=False)
platform._limiter.enqueue.assert_awaited_once()
platform._application.bot.edit_message_text.assert_awaited_once()
def test_fire_and_forget_non_coroutine_uses_ensure_future(monkeypatch):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
ef = MagicMock()
monkeypatch.setattr(asyncio, "ensure_future", ef)
platform.outbound.fire_and_forget(MagicMock())
ef.assert_called_once()
@pytest.mark.asyncio
async def test_on_start_command_replies_and_forwards():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
with patch.object(
platform, "_on_telegram_message", new_callable=AsyncMock
) as mock_msg:
update = MagicMock()
update.message.reply_text = AsyncMock()
await platform._on_start_command(update, MagicMock())
update.message.reply_text.assert_awaited_once()
mock_msg.assert_awaited_once()
@pytest.mark.asyncio
async def test_on_telegram_message_handler_error_sends_error_message():
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t", allowed_user_id="123")
with patch.object(
platform.outbound, "send_message", new_callable=AsyncMock
) as mock_send:
async def _boom(_incoming):
raise RuntimeError("bad")
platform.on_message(_boom)
update = MagicMock()
update.message.text = "hello"
update.message.message_id = 7
update.message.reply_to_message = None
update.effective_user.id = 123
update.effective_chat.id = 456
await platform._on_telegram_message(update, MagicMock())
mock_send.assert_awaited_once()
@pytest.mark.asyncio
async def test_telegram_start_retries_on_network_error(monkeypatch):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="token", allowed_user_id=None)
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
with patch("telegram.ext.Application.builder") as mock_builder:
mock_app = MagicMock()
mock_app.initialize = AsyncMock(side_effect=[NetworkError("no"), None])
mock_app.start = AsyncMock()
mock_app.updater = None
mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app
await platform.start()
assert platform.is_connected is True
assert mock_app.initialize.await_count == 2
mock_app.start.assert_awaited_once_with()
platform._limiter.start.assert_called_once_with()
@pytest.mark.asyncio
async def test_telegram_polling_retry_does_not_restart_running_application(
monkeypatch,
):
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="token", allowed_user_id=None)
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
with patch("telegram.ext.Application.builder") as mock_builder:
mock_app = MagicMock()
mock_app.initialize = AsyncMock()
mock_app.start = AsyncMock()
mock_app.updater.start_polling = AsyncMock(
side_effect=[NetworkError("temporary polling failure"), None]
)
mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app
await platform.start()
assert platform.is_connected is True
mock_app.initialize.assert_awaited_once_with()
mock_app.start.assert_awaited_once_with()
assert mock_app.updater.start_polling.await_count == 2
platform._limiter.start.assert_called_once_with()
@pytest.mark.asyncio
async def test_edit_message_with_text_exceeding_4096_raises():
"""edit_message with text > 4096 raises TelegramError (BadRequest)."""
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
platform._application.bot = AsyncMock()
platform._application.bot.edit_message_text = AsyncMock(
side_effect=TelegramError("Bad Request: message is too long")
)
with pytest.raises(TelegramError):
await platform.outbound.edit_message("c", "1", "x" * 5000)
@pytest.mark.asyncio
async def test_edit_message_empty_string():
"""edit_message with empty string - Telegram accepts (no-op edit)."""
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
platform._application.bot = AsyncMock()
platform._application.bot.edit_message_text = AsyncMock()
await platform.outbound.edit_message("c", "1", "")
platform._application.bot.edit_message_text.assert_awaited_once_with(
chat_id="c", message_id=1, text="", parse_mode="MarkdownV2"
)
@pytest.mark.asyncio
async def test_send_message_empty_string():
"""send_message with empty string - Telegram may reject; we pass through."""
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
platform._application = MagicMock()
mock_msg = MagicMock()
mock_msg.message_id = 1
platform._application.bot = AsyncMock()
platform._application.bot.send_message = AsyncMock(return_value=mock_msg)
msg_id = await platform.outbound.send_message("c", "")
assert msg_id == "1"
platform._application.bot.send_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_on_telegram_message_non_text_update_ignored():
"""Update with message.photo but no text returns early without calling handler."""
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t", allowed_user_id="123")
handler = AsyncMock()
platform.on_message(handler)
update = MagicMock()
update.message.text = None
update.message.photo = [MagicMock()]
update.message.message_id = 7
update.message.reply_to_message = None
update.effective_user.id = 123
update.effective_chat.id = 456
await platform._on_telegram_message(update, MagicMock())
handler.assert_not_called()
@pytest.mark.asyncio
async def test_with_retry_message_not_found_returns_none():
"""'message to edit not found' returns None without retry."""
with patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
):
platform = _telegram_runtime(bot_token="t")
async def _f():
raise TelegramError("message to edit not found")
result = await platform.outbound._with_retry(_f)
assert result is None