项目文件夹

文件
Ali Khokhar 5ffa47fbc3 Make response stream lifetimes explicit (#1060)
## 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 -->
2026-07-11 09:33:31 -07:00

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