Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ed90b01
refactor(cli): remove unused claude command and simplify serve module
CaddyGlow Oct 3, 2025
faa6bb7
perf(tests): optimize integration tests for faster CI execution
CaddyGlow Oct 3, 2025
3e36703
docs: update installation instructions and add new documentation
CaddyGlow Oct 4, 2025
e1a53ee
feat(async): introduce runtime facade for asyncio-to-anyio migration
CaddyGlow Oct 4, 2025
5474fff
refactor(scheduler): route task loop through async runtime
CaddyGlow Oct 4, 2025
95a2862
refactor(core): use runtime primitives in request context and hooks
CaddyGlow Oct 4, 2025
6316fb7
refactor(credential-balancer): align locks with runtime facade
CaddyGlow Oct 4, 2025
0f894ee
refactor(cli): rely on async runtime for detection helpers
CaddyGlow Oct 4, 2025
e8a7198
refactor(services): use runtime helpers in HTTP pool and mock handler
CaddyGlow Oct 4, 2025
fe0f004
refactor(claude-sdk): use runtime CancelledError
CaddyGlow Oct 4, 2025
3570fca
refactor(runtime): route auth and docker through runtime facade
CaddyGlow Oct 4, 2025
67727d6
refactor(scheduler): use runtime primitives for concurrency
CaddyGlow Oct 4, 2025
6aa520a
refactor(detection): use runtime helpers for CLI subprocesses
CaddyGlow Oct 4, 2025
5568170
refactor(permissions): route CLI handlers through runtime
CaddyGlow Oct 4, 2025
f76e01b
refactor: migrate remaining asyncio usage to runtime facade
CaddyGlow Oct 4, 2025
3a992c7
feat: switch async runtime to anyio
CaddyGlow Oct 4, 2025
f505af0
chore: document async runtime usage
CaddyGlow Oct 4, 2025
c780fef
feat(core): surface anyio primitives
CaddyGlow Oct 5, 2025
203a024
refactor(core): simplify async task manager
CaddyGlow Oct 5, 2025
345c2ca
refactor(hooks): replace thread executor with async tasks
CaddyGlow Oct 5, 2025
b3e1a46
refactor(hooks): route background manager through runtime
CaddyGlow Oct 5, 2025
7b46453
refactor(core): remove direct asyncio dependency from task manager
CaddyGlow Oct 5, 2025
5028e7c
test: add slow stress suites for hooks, tasks, and analytics
CaddyGlow Oct 5, 2025
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
6 changes: 4 additions & 2 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@ class ProviderAdapter(BaseAdapter):
## 8. Asynchronous Programming

* **`async`/`await`:** Use consistently for all I/O operations
* **Libraries:** Prefer `httpx` for HTTP, `asyncio` for concurrency
* **No Blocking Code:** Never use blocking I/O in async functions
* **Runtime Facade:** Import concurrency primitives from `ccproxy.core.async_runtime` (e.g., `create_task`, `sleep`, `create_lock`) instead of touching `asyncio` or `anyio` directly. This keeps the codebase portable as we evolve the underlying async backend.
* **Direct `asyncio` Usage:** Only interact with `asyncio` for functionality not yet exposed via the runtime (e.g., low-level loop or subprocess APIs). When that happens, contain it behind small helpers and add a TODO to fold it into the facade.
* **HTTP Clients:** Continue using `httpx` for network I/O.
* **No Blocking Code:** Never use blocking I/O in async functions; push work into `runtime.to_thread` when necessary.

## 9. Testing

Expand Down
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,18 @@ export PLUGINS__METRICS__ENABLED=true

## Running

To try the latest development branch without cloning the repo, use `uvx` to
install directly from GitHub:
To install the latest stable release without cloning the repository, use `uvx`
to grab the published wheel and launch the CLI:

```bash
uvx --with 'ccproxy-api[all] @ git+https://github.com/caddyglow/ccproxy-api@dev/v0.2' ccproxy
uvx --with "ccproxy-api[all]==0.2.0" ccproxy serve --port 8000
```

Or, if you prefer `pipx`, you can run the same branch directly:

```bash
pipx run --spec 'ccproxy-api[all] @ git+https://github.com/caddyglow/ccproxy-api@dev/v0.2' ccproxy
```
If you prefer `pipx`, install the package (optionally with extras) and use the
local shim:

```bash
pipx install "ccproxy-api[all]==0.2.0"
ccproxy serve # default on localhost:8000
```

Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ tests/
- Mock at **service boundaries only** - never mock internal components
- Test **pure functions and single components** in isolation
- **No HTTP layer testing** - use service layer mocks instead
- **No timing dependencies** - all asyncio.sleep() removed
- **No timing dependencies** - all direct `asyncio.sleep()` calls should instead patch `ccproxy.core.async_runtime.sleep`
- **No database operations** - moved to integration tests

**Integration Tests** (tests/integration/):
Expand Down
2 changes: 1 addition & 1 deletion ccproxy/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async def shutdown_hook_system(app: FastAPI) -> None:
# Get hook manager from app state - it will shutdown its own background manager
hook_manager = getattr(app.state, "hook_manager", None)
if hook_manager:
hook_manager.shutdown()
await hook_manager.shutdown()

logger.debug("hook_system_shutdown_completed", category="lifecycle")
except Exception as e:
Expand Down
24 changes: 14 additions & 10 deletions ccproxy/auth/oauth/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Base OAuth client with common PKCE flow implementation."""

import asyncio
import base64
import hashlib
import secrets
import urllib.parse
from abc import ABC, abstractmethod
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from typing import Any, Generic, TypeVar

Expand All @@ -18,6 +18,15 @@
from ccproxy.auth.models.credentials import BaseCredentials
from ccproxy.auth.storage.base import TokenStorage
from ccproxy.config.settings import Settings
from ccproxy.core.async_runtime import (
Task,
)
from ccproxy.core.async_runtime import (
create_event as runtime_create_event,
)
from ccproxy.core.async_runtime import (
create_task as runtime_create_task,
)
from ccproxy.core.logging import get_logger
from ccproxy.http.client import HTTPClientFactory

Expand Down Expand Up @@ -89,8 +98,8 @@ def __init__(
hook_manager_id=id(hook_manager) if hook_manager else None,
)

self._callback_server: asyncio.Task[None] | None = None
self._auth_complete = asyncio.Event()
self._callback_server: Task[None] | None = None
self._auth_complete = runtime_create_event()
self._auth_result: Any | None = None
self._auth_error: str | None = None

Expand All @@ -106,13 +115,8 @@ def __del__(self) -> None:
and self.http_client
and not self.http_client.is_closed
):
try:
# Try to get the current event loop
loop = asyncio.get_running_loop()
loop.create_task(self.http_client.aclose())
except RuntimeError:
# No running event loop, can't clean up async resources
pass
with suppress(RuntimeError):
runtime_create_task(self.http_client.aclose())

def _generate_pkce_pair(self) -> tuple[str, str]:
"""Generate PKCE code verifier and challenge.
Expand Down
46 changes: 33 additions & 13 deletions ccproxy/auth/oauth/flows.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""OAuth flow engines for CLI authentication."""

import asyncio
import base64
import secrets
import sys
Expand All @@ -13,6 +12,25 @@

from ccproxy.auth.oauth.cli_errors import AuthProviderError, PortBindError
from ccproxy.auth.oauth.registry import OAuthProviderProtocol
from ccproxy.core.async_runtime import (
Future,
Task,
)
from ccproxy.core.async_runtime import (
TimeoutError as RuntimeTimeoutError,
)
from ccproxy.core.async_runtime import (
create_future as runtime_create_future,
)
from ccproxy.core.async_runtime import (
create_task as runtime_create_task,
)
from ccproxy.core.async_runtime import (
sleep as runtime_sleep,
)
from ccproxy.core.async_runtime import (
wait_for as runtime_wait_for,
)
from ccproxy.core.logging import get_logger


Expand All @@ -33,10 +51,10 @@ def __init__(self, port: int, callback_path: str = "/callback") -> None:
self.port = port
self.callback_path = callback_path
self.server: Any = None
self._server_task: asyncio.Task[Any] | None = None
self._server_task: Task[Any] | None = None
self.callback_received = False
self.callback_data: dict[str, Any] = {}
self.callback_future: asyncio.Future[dict[str, Any]] | None = None
self.callback_future: Future[dict[str, Any]] | None = None

async def start(self) -> None:
"""Start the callback server."""
Expand Down Expand Up @@ -92,10 +110,10 @@ async def _serve_with_error_handling() -> None:
f"Failed to start callback server on port {self.port}: {e}"
) from e

self._server_task = asyncio.create_task(_serve_with_error_handling())
self._server_task = runtime_create_task(_serve_with_error_handling())

# Wait briefly and check if server started successfully
await asyncio.sleep(0.1)
await runtime_sleep(0.1)
if self._server_task.done():
# Server failed to start, re-raise the exception
await self._server_task
Expand All @@ -110,8 +128,8 @@ async def stop(self) -> None:
self.server.should_exit = True
if hasattr(self, "_server_task") and self._server_task is not None:
try:
await asyncio.wait_for(self._server_task, timeout=2.0)
except TimeoutError:
await runtime_wait_for(self._server_task, timeout=2.0)
except RuntimeTimeoutError:
self._server_task.cancel()
self.server = None
logger.debug("cli_callback_server_stopped", port=self.port)
Expand Down Expand Up @@ -182,14 +200,14 @@ async def wait_for_callback(
Callback data dictionary

Raises:
asyncio.TimeoutError: If callback is not received within timeout
RuntimeTimeoutError: If callback is not received within timeout
ValueError: If state validation fails
"""
self.callback_future = asyncio.Future()
self.callback_future = runtime_create_future()

try:
# Wait for callback with timeout
callback_data = await asyncio.wait_for(
callback_data = await runtime_wait_for(
self.callback_future, timeout=timeout
)

Expand All @@ -215,9 +233,11 @@ async def wait_for_callback(

return callback_data

except TimeoutError:
except RuntimeTimeoutError:
logger.error("cli_callback_timeout", timeout=timeout, port=self.port)
raise TimeoutError(f"No OAuth callback received within {timeout} seconds")
raise RuntimeTimeoutError(
f"No OAuth callback received within {timeout} seconds"
)


def render_qr_code(url: str) -> None:
Expand Down Expand Up @@ -311,7 +331,7 @@ async def run(
callback_data["code"], state, code_verifier, redirect_uri
)
return await provider.save_credentials(credentials, save_path)
except TimeoutError:
except RuntimeTimeoutError:
# Fallback to manual code entry if callback times out
console.print(
"[yellow]Callback timed out. You can enter the code manually.[/yellow]"
Expand Down
14 changes: 7 additions & 7 deletions ccproxy/auth/storage/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Abstract base class for token storage."""

import asyncio
import contextlib
import json
import shutil
Expand All @@ -11,6 +10,7 @@

from ccproxy.auth.exceptions import CredentialsInvalidError, CredentialsStorageError
from ccproxy.auth.models.credentials import BaseCredentials
from ccproxy.core.async_runtime import to_thread as runtime_to_thread
from ccproxy.core.logging import get_logger


Expand Down Expand Up @@ -113,7 +113,7 @@ def read_file() -> dict[str, Any]:
with self.file_path.open("r") as f:
return json.load(f) # type: ignore[no-any-return]

data = await asyncio.to_thread(read_file)
data = await runtime_to_thread(read_file)
return data

except json.JSONDecodeError as e:
Expand Down Expand Up @@ -166,7 +166,7 @@ async def _create_backup(self) -> bool:
backup_path = self.file_path.parent / backup_name

# Copy file to backup location
await asyncio.to_thread(shutil.copy2, self.file_path, backup_path)
await runtime_to_thread(shutil.copy2, self.file_path, backup_path)

logger.info(
"backup_created",
Expand Down Expand Up @@ -207,7 +207,7 @@ async def _write_json(self, data: dict[str, Any]) -> None:

try:
# Ensure parent directory exists
await asyncio.to_thread(
await runtime_to_thread(
self.file_path.parent.mkdir,
parents=True,
exist_ok=True,
Expand All @@ -225,7 +225,7 @@ def write_file() -> None:
# Atomic rename
temp_path.replace(self.file_path)

await asyncio.to_thread(write_file)
await runtime_to_thread(write_file)

logger.debug(
"json_write_success",
Expand Down Expand Up @@ -273,7 +273,7 @@ async def exists(self) -> bool:
True if file exists, False otherwise
"""
# Run file system check in thread pool for consistency
file_exists = await asyncio.to_thread(
file_exists = await runtime_to_thread(
lambda: self.file_path.exists() and self.file_path.is_file()
)

Expand All @@ -297,7 +297,7 @@ async def delete(self) -> bool:
"""
try:
if await self.exists():
await asyncio.to_thread(self.file_path.unlink)
await runtime_to_thread(self.file_path.unlink)
logger.debug("file_deleted", path=str(self.file_path))
return True
return False
Expand Down
3 changes: 1 addition & 2 deletions ccproxy/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .commands.serve import api, claude
from .commands.serve import api
from .helpers import get_rich_toolkit
from .main import app, app_main, main, version_callback

Expand All @@ -8,7 +8,6 @@
"main",
"version_callback",
"api",
"claude",
"app_main",
"get_rich_toolkit",
]
Loading
Loading