alishahryar1--free-claude-code
5ffa47fbc3
## Problem Client disconnects and response-start send failures could abandon a prefetched provider stream and its generation lease. Re-yielding iterators and response-proxy middleware left no owner that closed the complete body chain before runtime release. ## Changes | Before | After | | --- | --- | | Starlette body iteration indirectly owned stream cleanup and lease release. | One FCC streaming response surrounds the real ASGI send, closes the body transitively, then releases the lease exactly once. | | The prefetched first-frame generator could not close its tail before replay began. | An explicit closeable replay iterator owns the prefetched tail in every commit state. | | Tracing, execution, Responses conversion, and native transport transforms re-yielded inputs without closing them. | Every retained transform closes its direct input; redundant transport wrappers are removed while provider construction failures remain deferred. | | Function-style correlation middleware proxied and canceled streaming responses. | Pure ASGI correlation spans the complete stream, preserves request headers and log context, and keeps the catch-all 500 fallback correlated. | | Repeated cancellation could interrupt pre-start and post-start cleanup. | Shielded completion tasks finish body closure before release and then restore caller cancellation. | | The package version was 3.5.5. | The package version is 3.5.6; full CI passes with 2,162 tests and stable live API/provider/disconnect/client smoke passes 63 scenarios. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR makes streaming response ownership explicit across the API path. The main changes are: - Adds a managed streaming response that closes the body chain before releasing provider resources. - Adds a prefetched replay iterator for first-frame commit handling. - Moves request correlation to pure ASGI middleware for full-stream context. - Propagates direct-input closure through execution, tracing, Responses conversion, and provider transports. - Bumps the package version and updates tests for stream cleanup behavior. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues found in the changed code. None. <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** - Validated the execution environment by reviewing the environment proof log, confirming uv 0.11.28, CPython 3.14.0, a repo-local virtual environment, and exit code 0. - Verified that the requested test command was executed, based on the test proof log. - Confirmed the test run completed successfully with 91 tests passing in 3.63 seconds, as shown in the test proof log. <a href="https://app.greptile.com/trex/runs/14099258/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/api/response_streams.py | Adds the managed response owner, first-frame replay iterator, and shielded cleanup flow. | | src/free_claude_code/api/request_ids.py | Adds pure ASGI request correlation and response-start header injection. | | src/free_claude_code/core/trace.py | Adds shared stream input closure tracing and closes traced inputs on exit. | | src/free_claude_code/application/execution.py | Closes provider stream iterators from the executor wrapper when streaming ends. | | src/free_claude_code/providers/transports/anthropic_messages/transport.py | Returns provider runner streams directly and closes layered SSE iterators explicitly. | </details> <sub>Reviews (2): Last reviewed commit: ["Make response stream lifetimes explicit"](https://github.com/alishahryar1/free-claude-code/commit/cb698c62c08924d5f80a1cea7dbd19c0b8af26a2) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=43527620)</sub> <!-- /greptile_comment -->
141 行
4.2 KiB
Python
141 行
4.2 KiB
Python
"""Tests for the pure-ASGI ingress correlation owner."""
|
|
|
|
import asyncio
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from typing import cast
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
from starlette.datastructures import Headers
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.types import ASGIApp, Message, Scope
|
|
|
|
from free_claude_code.api.request_ids import (
|
|
RequestCorrelationMiddleware,
|
|
get_request_id,
|
|
)
|
|
from tests.api.support import create_test_app
|
|
|
|
|
|
def _http_scope(path: str) -> Scope:
|
|
return cast(
|
|
Scope,
|
|
{
|
|
"type": "http",
|
|
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
|
"http_version": "1.1",
|
|
"method": "POST",
|
|
"scheme": "http",
|
|
"path": path,
|
|
"raw_path": path.encode(),
|
|
"query_string": b"",
|
|
"headers": [(b"anthropic-session-id", b"session_test")],
|
|
"client": None,
|
|
"server": None,
|
|
},
|
|
)
|
|
|
|
|
|
def test_application_uses_the_pure_asgi_correlation_owner() -> None:
|
|
app = create_test_app()
|
|
middleware_classes = [middleware.cls for middleware in app.user_middleware]
|
|
|
|
assert sum(cls is RequestCorrelationMiddleware for cls in middleware_classes) == 1
|
|
assert all(cls is not BaseHTTPMiddleware for cls in middleware_classes)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_correlation_context_and_headers_span_the_complete_stream() -> None:
|
|
response_started = asyncio.Event()
|
|
allow_body = asyncio.Event()
|
|
sent: list[Message] = []
|
|
context_entries: list[dict[str, object]] = []
|
|
context_exits: list[dict[str, object]] = []
|
|
app_request_id: str | None = None
|
|
|
|
async def app(scope: Scope, _receive, send) -> None:
|
|
nonlocal app_request_id
|
|
app_request_id = get_request_id(Request(scope))
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [],
|
|
}
|
|
)
|
|
response_started.set()
|
|
await allow_body.wait()
|
|
await send(
|
|
{
|
|
"type": "http.response.body",
|
|
"body": b"done",
|
|
"more_body": False,
|
|
}
|
|
)
|
|
|
|
@contextmanager
|
|
def contextualize(**fields: object) -> Iterator[None]:
|
|
context_entries.append(fields)
|
|
try:
|
|
yield
|
|
finally:
|
|
context_exits.append(fields)
|
|
|
|
async def receive() -> Message:
|
|
raise AssertionError("Test application does not receive messages")
|
|
|
|
async def send(message: Message) -> None:
|
|
sent.append(message)
|
|
|
|
middleware = RequestCorrelationMiddleware(cast(ASGIApp, app))
|
|
with patch(
|
|
"free_claude_code.api.request_ids.logger.contextualize",
|
|
side_effect=contextualize,
|
|
):
|
|
request = asyncio.create_task(
|
|
middleware(_http_scope("/v1/responses"), receive, send)
|
|
)
|
|
await response_started.wait()
|
|
|
|
assert context_exits == []
|
|
assert app_request_id is not None
|
|
headers = Headers(raw=sent[0]["headers"])
|
|
assert headers["request-id"] == app_request_id
|
|
assert headers["x-request-id"] == app_request_id
|
|
assert context_entries == [
|
|
{
|
|
"http_method": "POST",
|
|
"http_path": "/v1/responses",
|
|
"claude_session_id": "session_test",
|
|
"request_id": app_request_id,
|
|
}
|
|
]
|
|
|
|
allow_body.set()
|
|
await request
|
|
|
|
assert context_exits == context_entries
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_correlation_middleware_passes_non_http_scopes_unchanged() -> None:
|
|
observed_scope: Scope | None = None
|
|
|
|
async def app(scope: Scope, _receive, _send) -> None:
|
|
nonlocal observed_scope
|
|
observed_scope = scope
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "lifespan.startup"}
|
|
|
|
async def send(_message: Message) -> None:
|
|
return None
|
|
|
|
scope = cast(Scope, {"type": "lifespan", "asgi": {"version": "3.0"}})
|
|
await RequestCorrelationMiddleware(cast(ASGIApp, app))(scope, receive, send)
|
|
|
|
assert observed_scope is scope
|
|
assert "state" not in scope
|