Conversation
_CodexCompletionsAdapter.create arms a daemon threading.Timer that calls client.close() when the aux Responses stream exceeds its timeout. On a stalled stream -- the failure the timeout exists for -- the Timer is the only thing that fires, so the close runs on a thread that does not own the in-flight httpx connection. That is the FD-ownership violation the repo already fixed twice on the main transport (NousResearch#29507, NousResearch#67142, NousResearch#70773): close() releases the raw TLS fd while the owner's OpenSSL BIO still caches that integer, the kernel recycles it into the next open() in the process -- a SessionDB or kanban.db handle -- and the owner's unwinding TLS flush writes an application-data record into that database file. agent/auxiliary_client.py had no thread-ownership machinery at all: the guarded twins (_retire_shared_openai_client, _abort_request_openai_client) live in run_agent.py and are unreachable from this adapter, which holds no AIAgent reference. Dispatch on ownership the way chat_completion_helpers already does: from a stranger thread only force_close_tcp_sockets() (shutdown(SHUT_RDWR), which is FD-safe from any thread), and let the owning thread release the FDs when it unwinds. The owner-thread caller (_check_cancelled) keeps closing directly. Cache eviction (NousResearch#23432) is unchanged.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the Timer-thread close to the existing FD-recycling guard.
Problems
- The proposed owner-side close is still unsafe for this client lifecycle.
agent/auxiliary_client.py:6681-6700hands the same cached client to concurrent callers with no borrower tracking. After a timeout, the addedclient.close()in the timed-out request'sfinallycan release pooled FDs owned by another active worker. The main shared-client path deliberately avoids that:run_agent.py:4546-4565uses shutdown-only retirement and defers release until borrowers unwind.
Suggested changes
- Make timeout cleanup shared-client-safe: avoid closing the cached leaf from one request's
finally; use request-local ownership or a lease/refcount lifecycle, and add a two-borrower regression test.
Automated hermes-sweeper review.
| # A stranger-thread timeout only shut the sockets down; the FDs are | ||
| # still open and this — the owning thread, now unwound — is the one | ||
| # context that may release them (#29507). | ||
| if timed_out.is_set(): |
There was a problem hiding this comment.
self._client is cache-shared (agent/auxiliary_client.py:6681-6700), so this request's owner thread may not own another worker's active pooled connection. Closing here can still release that other worker's FD; use a shared-client lease/lifecycle or defer release after all borrowers unwind.
|
Salvaged and merged in PR #99709 (merge SHA 8a766c3) with your commit authorship preserved — thank you for the thorough FD-ownership analysis, it was spot on. Your fix was reapplied onto the re-armable no-progress watchdog that landed today in #99660, plus one addition your original predates: the stranger-thread path also closes the attempt-owned event stream so an owner blocked inside the SDK stream (no reachable raw socket) still unwinds promptly. |
Summary
_CodexCompletionsAdapter.createarms a daemonthreading.Timerthat callsclient.close()when the auxiliary Responses stream exceeds its timeout. On a stalled stream — the exact failure the timeout exists for — the Timer is the only thing that fires, soclose()runs on a thread that does not own the in-flight httpx connection.That is the file-descriptor ownership violation this repo has already fixed twice on the main transport (#29507, #67142, #70773).
agent/auxiliary_client.pynever received the same treatment: it contains no thread-ownership machinery at all.Problem
The rule is stated in the repo's own guard,
agent/agent_runtime_helpers.py:3505force_close_tcp_sockets:and its docstring spells out the consequence: the
ssl.SSLSocket's OpenSSLBIOcaches the raw integer fd, so onceos.close(fd)runs the kernel may immediately recycle that integer to the nextopen()— "e.g. the kanban dispatcher openingkanban.db" — and the owning thread's unwinding TLS flush writes the encrypted bytes into the wrong file (#29507: a 24-byte TLS application-data record clobbering SQLite header bytes 5..28).The guarded twins enforce exactly this, and their comments name the hazard:
run_agent.py:4393_retire_shared_openai_client— "Only an owner may release FDs … So nobody calls close(): we shutdown() the pooled sockets … and defer the actual FD release to garbage collection."run_agent.py:4553_abort_request_openai_client— "Callingclient.close()from a thread that does not own the active httpx connection raced the still-live SSL BIO and corrupted unrelated file descriptors when the kernel recycled the just-freed TCP FD (Interrupted OpenAI/httpx request thread survives across turns and writes TLS record bytes to unrelated file descriptors on delayed close #29507)."agent/chat_completion_helpers.py:2594-2620viastranger_thread = ... owner_tid != threading.get_ident().The unguarded path.
agent/auxiliary_client.py:1150, inside_close_client_on_timeout:grep -n "get_ident|force_close_tcp_sockets|SHUT_RDWR|shutdown(" agent/auxiliary_client.pyreturns zero hits. The guarded twins live inrun_agent.pyand are unreachable here —_CodexCompletionsAdapterholds noAIAgentreference.The same callback is invoked from two threads with no dispatch between them:
_check_cancelled()runs it on the owning thread (safe), and thethreading.Timerarmed at line 1186 runs it on a stranger thread (unsafe). Once the Timer setstimed_out, line 1166'sif not timed_out.is_set()means the stranger-thread close is the only close.Why the Timer wins.
_check_cancelledis reachable only from_on_each_event, i.e. only when an SSE event actually arrives. The Timer fires at the deadline unconditionally. On a stalled body no event ever arrives, so the owner-thread path cannot run at all.Trigger. Main or auxiliary provider on a Responses-only backend (
openai-codexChatGPT OAuth, orxai-oauth) —_resolve_autoStep 1 routes aux tasks to the user's main provider, so this needs no extra config. Then any ordinary long session where compression,flush_memories, MoA aggregation or title generation exceeds its timeout while still streaming._effective_aux_timeoutalways returns a positive value (_DEFAULT_AUX_TIMEOUT = 30.0; compression floored at 300s), so the Timer is always armed.Blast radius. The leaf client is process-global (
_client_cache, keyed per provider/model) and shared across compression, memory flush, MoA, title generation, session_search and web_tools — andAsyncCodexAuxiliaryClientruns the same sync adapter onasyncio.to_threadworkers, so the Timer'sclose()can also kill a connection owned by an unrelated worker thread.Reproduced
Both legs of the causal chain, in-process, driving the real
_CodexCompletionsAdapter:The 24-byte TLS application-data record lands on the SQLite header — the documented #29507 signature. Corruption there sends
SessionDBdown the quarantine path, which opens a fresh empty database: every session in the old file is gone from the running product. There is no spill file, noactive=0twin (the damaged bytes are the storage), no content-bearing log, andcreate_quick_snapshotis not continuous — it runs onhermes updateand deliberately skips a largestate.db.Fix
Dispatch on ownership, mirroring
agent/chat_completion_helpers.py:owner_tid = threading.get_ident()at the top ofcreate()._close_client_on_timeout, the owning thread keeps closing directly; a stranger thread only callsforce_close_tcp_sockets()(shutdown(SHUT_RDWR)), which unblocks the owner's pendingrecvwithout releasing any fd.close()in thefinallyonce it has unwound — where the FD release belongs.Cache eviction on timeout (#23432) is unchanged.
Scope
agent/auxiliary_client.py— ownership dispatch in_close_client_on_timeout; owner-side close in the existingfinally. No change to timeout values, retry behaviour, cache eviction, or the Responses streaming logic.Testing
New
TestCodexAuxiliaryTimeoutFdOwnershipintests/agent/test_auxiliary_client.py, built on the nested_client._transport._pool._connectionsshape thatforce_close_tcp_socketstraverses, recording(action, thread)for every socket operation:test_stalled_stream_timeout_does_not_release_fds_from_timer_thread— on a stalled stream the stranger thread performsshutdownand neverclient.close/sock.close.test_owner_thread_releases_fds_after_a_stranger_thread_timeout— the deferred close still happens, on the owner, and strictly after theshutdown.Both fail on
mainand pass here:The two pre-existing tests in
TestCodexAuxiliaryAdapterTimeoutpass unmodified, so timeout forwarding and timing behaviour are unchanged.