Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
113 changes: 87 additions & 26 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,16 @@ def direct_api_call(agent, api_kwargs: dict):

def _abort_active_request(reason: str) -> None:
"""Abort the inline request from a watchdog/interrupt thread."""
# Abort while still holding the holder lock: the instant it is
# released, the inline finally may pop + cache the client for reuse
# and the NEXT call check it out — a late abort would then poison
# the slot and shut down an innocent in-flight request's sockets
# (same atomicity contract as _close_request_client_once in the
# interruptible variants; the abort itself never blocks).
with request_client_lock:
request_client = request_client_holder["client"]
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)

def _make_client(reason: str, kind: str = "openai"):
# direct_api_call only runs for OpenAI-wire chat_completions cron
Expand All @@ -535,6 +541,10 @@ def _make_client(reason: str, kind: str = "openai"):
agent._active_request_abort = _abort_active_request
return client

# Only a clean return may report the reuse reason (request_complete):
# after an error or interrupt the wire client is really closed so the
# retry builds a fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
succeeded = False
try:
response = _dispatch_nonstreaming_api_request(
agent, api_kwargs, make_client=_make_client
Expand All @@ -547,6 +557,7 @@ def _make_client(reason: str, kind: str = "openai"):
if getattr(agent, "_interrupt_requested", False):
raise InterruptedError("Agent interrupted during API call")
_reset_stale_streak(agent)
succeeded = True
return response
finally:
if getattr(agent, "_active_request_abort", None) is _abort_active_request:
Expand All @@ -555,7 +566,10 @@ def _make_client(reason: str, kind: str = "openai"):
request_client = request_client_holder["client"]
request_client_holder["client"] = None
if request_client is not None:
agent._close_request_openai_client(request_client, reason="request_complete")
agent._close_request_openai_client(
request_client,
reason="request_complete" if succeeded else "request_error_cleanup",
)


def interruptible_api_call(agent, api_kwargs: dict):
Expand Down Expand Up @@ -633,20 +647,28 @@ def _close_request_client_once(reason: str) -> None:
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
# Owning thread (or no recorded owner) → pop and fully close.
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if stranger_thread:
# Abort while still holding the holder lock: the instant it
# is released, the worker's finally may pop + cache the client
# for reuse and the NEXT call check it out — an abort landing
# after that would poison the slot and shut down an innocent
# in-flight request's sockets. The abort itself never blocks
# (socket shutdown + slot poison), so holding the lock across
# it only delays the racing pop, never the data path.
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._abort_request_anthropic_client(
request_client, reason=reason
)
else:
agent._abort_request_openai_client(request_client, reason=reason)
return
# Owning thread (or no recorded owner) → pop and fully close.
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._close_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)

Expand Down Expand Up @@ -683,7 +705,15 @@ def _call():
return
result["error"] = e
finally:
_close_request_client_once("request_complete")
# Reuse reason only on a clean response; any other outcome —
# error, or the cancel-swallow return above (which leaves both
# result slots None) — really closes so the next attempt builds
# a fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
_close_request_client_once(
"request_complete"
if result["response"] is not None
else "request_error_cleanup"
)

# ── Stale-call timeout (mirrors streaming stale detector) ────────
# Non-streaming calls return nothing until the full response is
Expand Down Expand Up @@ -2642,20 +2672,27 @@ def _close_request_client_once(reason: str) -> None:
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if stranger_thread:
# Abort under the holder lock — see the non-streaming variant
# for why the holder read and the abort must be atomic (a late
# abort would otherwise hit the NEXT request's checkout).
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._abort_request_anthropic_client(
request_client, reason=reason
)
else:
agent._abort_request_openai_client(request_client, reason=reason)
return
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
# Stranger threads returned under the lock above, so only the owner
# (or an any-thread-safe stream handle) reaches the close dispatch.
if request_kind == "stream":
_close_request_stream_handle(request_client, reason)
elif request_kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
agent._close_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)

Expand Down Expand Up @@ -2944,6 +2981,23 @@ def _call_chat_completions(stream_attempt_id: int):
pass

if agent._interrupt_requested:
# Abandoning a half-read SSE response leaves its connection
# permanently checked out of the httpx pool — and the partial
# response built below makes the worker's finally report a
# reuse-reason close, which would cache the client together
# with the leaked connection (each interrupt leaking one more
# until the pool exhausts). Close the stream here, on the
# owning thread, so the connection is released first.
try:
stream.close()
except Exception:
# Connection may still be checked out — poison the slot so
# the finally's close really closes the pool instead of
# caching it (owner-thread abort: shutdown is safe, and the
# FD release still happens in the finally below).
agent._abort_request_openai_client(
request_client, reason="interrupt_stream_close_failed"
)
break

if not _stream_attempt_is_active(stream_attempt_id):
Expand Down Expand Up @@ -3695,7 +3749,14 @@ def _call():
result["error"] = e
return
finally:
_close_request_client_once("stream_request_complete")
# Reuse reason only on a clean stream; any other outcome (error,
# cancel-swallow) really closes so the next attempt builds a
# fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
_close_request_client_once(
"stream_request_complete"
if result["response"] is not None
else "stream_error_cleanup"
)

# Provider-configured stale timeout takes priority over env default.
_cfg_stale = get_provider_stale_timeout(agent.provider, agent.model)
Expand Down
15 changes: 14 additions & 1 deletion agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1347,7 +1347,20 @@ def _interrupt_or_superseded(_tok=_writer_token) -> bool:
try:
close_fn()
except Exception:
pass
# A failed close can leave this response's connection
# checked out of the httpx pool while the caller's finally
# reports a reuse-reason close (e.g. interrupt_check broke
# the event loop with collected output) — caching the
# client with the leaked connection. Poison the slot so
# that close really closes the pool (owner-thread abort;
# mirrors the chat-streaming interrupt-break handling).
# ``client is None`` means the shared primary client,
# which is never reuse-cached and must not have its
# sockets force-shut here.
if client is not None:
agent._abort_request_openai_client(
active_client, reason="codex_stream_close_failed"
)


def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):
Expand Down
131 changes: 130 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3874,6 +3874,13 @@ def release_clients(self) -> None:
except Exception:
pass

# Also drop the cached per-request wire client (reused across
# sequential LLM calls) — same socket/memory rationale as above.
try:
self._close_cached_request_openai_client(reason="cache_evict")
except Exception:
pass

def close(self) -> None:
"""Release all resources held by this agent instance.

Expand Down Expand Up @@ -3930,6 +3937,13 @@ def close(self) -> None:
except Exception:
pass

# 5b. Close the cached per-request wire client (reused across
# sequential LLM calls; see _create_request_openai_client).
try:
self._close_cached_request_openai_client(reason="agent_close")
except Exception:
pass

# 6. Free conversation history. Mirrors _release_evicted_agent_soft's
# soft-eviction clear — close() is the hard teardown for true session
# boundaries (/new, /reset, session expiry), so the message list won't
Expand Down Expand Up @@ -4639,6 +4653,27 @@ def _copilot_headers_for_request(self, *, is_vision: bool) -> dict:

return copilot_request_headers(is_agent_turn=True, is_vision=is_vision)

# Close reasons the request workers' own ``finally`` unwind reports for
# a request that produced a response — the only closes that both come
# from the thread that owns the pool's FDs AND attest a healthy pool.
# Only these may keep the wire client for the next call, and poisoning
# still wins: a cross-thread abort (#29507) marks the slot so even a
# worker-finally close discards it. Every other reason (error cleanups,
# stale/interrupt kills, retry cleanups) gets a real close, so a retry
# after a request error always builds a fresh pool.
_REQUEST_CLIENT_REUSE_REASONS = frozenset({
"request_complete",
"stream_request_complete",
})

def _request_client_cache_ref(self) -> dict:
# Lazy init — tests build agents via AIAgent.__new__ without __init__.
cache = getattr(self, "_request_client_cache", None)
if cache is None:
cache = {"client": None, "kwargs": None, "poisoned": False, "in_use": False}
self._request_client_cache = cache
return cache

def _create_request_openai_client(self, *, reason: str, api_kwargs: Optional[dict] = None) -> Any:
from unittest.mock import Mock

Expand All @@ -4665,9 +4700,96 @@ def _create_request_openai_client(self, *, reason: str, api_kwargs: Optional[dic
and self._api_kwargs_have_image_parts(api_kwargs or {})
):
request_kwargs["default_headers"] = self._copilot_headers_for_request(is_vision=True)
return self._create_openai_client(request_kwargs, reason=reason, shared=False)
# Reuse the cached wire client while the effective kwargs are
# unchanged: constructing openai.OpenAI + its httpx pool costs
# ~19-35ms per LLM call (fresh TCP+TLS handshake), ~5x per turn.
# The cache is a single checked-out slot: `in_use` prevents two
# concurrent calls from sharing one pool's close/abort lifecycle
# (a second concurrent call gets a fresh untracked client with
# the old build-per-request behavior).
stale = None
with self._openai_client_lock():
cache = self._request_client_cache_ref()
cached = cache["client"]
if cached is not None and not cache["in_use"]:
if (
not cache["poisoned"]
and cache["kwargs"] == request_kwargs
and not self._is_openai_client_closed(cached)
):
cache["in_use"] = True
return cached
# kwargs changed (credential rotation, provider failover),
# poisoned by a cross-thread abort (#29507), or externally
# closed — never reuse; discard and rebuild below.
stale = cached
cache["client"] = None
cache["kwargs"] = None
cache["poisoned"] = False
if stale is not None:
# Safe to close from this thread: in_use was False, so no
# worker thread owns the pool's FDs (#29507 concerns clients
# with an in-flight request on another thread).
self._close_openai_client(stale, reason=f"reuse_evict:{reason}", shared=False)
client = self._create_openai_client(request_kwargs, reason=reason, shared=False)
with self._openai_client_lock():
cache = self._request_client_cache_ref()
if cache["client"] is None:
cache["client"] = client
# Snapshot nested dicts (default_headers): rotation sites
# assign fresh inner dicts today, but an aliased inner
# object would compare equal even after in-place mutation.
cache["kwargs"] = {
k: dict(v) if isinstance(v, dict) else v
for k, v in request_kwargs.items()
}
cache["poisoned"] = False
cache["in_use"] = True
# else: a concurrent call holds the slot — hand this client
# out untracked; _close_request_openai_client fully closes
# untracked clients, preserving the per-request lifecycle.
return client

def _close_request_openai_client(self, client: Any, *, reason: str) -> None:
with self._openai_client_lock():
cache = self._request_client_cache_ref()
if cache["client"] is client:
if reason in self._REQUEST_CLIENT_REUSE_REASONS and not cache["poisoned"]:
# Clean finish on the owning thread — keep the wire client
# (and its warm httpx pool) for the next sequential call.
cache["in_use"] = False
return
# Failure / kill / abort outcome: drop the slot and fall
# through to a real close. This runs on the owning worker
# thread, which is where the FD release belongs (#29507).
cache["client"] = None
cache["kwargs"] = None
cache["poisoned"] = False
cache["in_use"] = False
self._close_openai_client(client, reason=reason, shared=False)

def _close_cached_request_openai_client(self, *, reason: str) -> None:
"""Teardown hook: really close the cached per-request wire client."""
with self._openai_client_lock():
cache = getattr(self, "_request_client_cache", None)
client = cache["client"] if cache else None
in_use = bool(cache["in_use"]) if cache else False
if cache is not None:
cache["client"] = None
cache["kwargs"] = None
cache["poisoned"] = False
cache["in_use"] = False
if client is None:
return
if in_use:
# A worker thread has this client checked out for an in-flight
# request (workers can outlive turns — see interruptible_api_call).
# client.close() here would release its FDs from a stranger thread,
# the #29507 race teardown must not reintroduce. Abort the sockets
# instead; the slot is already cleared, so the worker's own finally
# sees an untracked client and does the real close on its thread.
self._abort_request_openai_client(client, reason=f"{reason}_in_flight")
return
self._close_openai_client(client, reason=reason, shared=False)

def _abort_request_openai_client(self, client: Any, *, reason: str) -> None:
Expand All @@ -4686,6 +4808,13 @@ def _abort_request_openai_client(self, client: Any, *, reason: str) -> None:
"""
if client is None:
return
# A pool whose sockets were shut down from a stranger thread must
# never be reused: poison the cache slot so the owner-thread close
# discards it and the next create builds a fresh client.
with self._openai_client_lock():
cache = self._request_client_cache_ref()
if cache["client"] is client:
cache["poisoned"] = True
try:
shutdown_count = self._force_close_tcp_sockets(client)
logger.info(
Expand Down
Loading
Loading