161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
"""Regression: the timeout watchdog must not release the client's FDs.
|
|
|
|
Salvaged from PR #72260 (@necoweb3 / dsad), adapted to the re-armable
|
|
no-progress watchdog introduced in PR #99660.
|
|
|
|
``close()`` from a thread that does not own the in-flight httpx connection
|
|
releases the raw TLS fd while the owner's OpenSSL BIO still caches that
|
|
integer. The kernel can hand the same integer to the next ``open()`` in the
|
|
process — a SessionDB or kanban.db handle — and the owner's unwinding TLS
|
|
flush then writes an application-data record into that database file
|
|
(#29507 / #67142 / #70773). ``shutdown()`` is FD-safe from any thread;
|
|
``close()`` is not, so it belongs to the owning thread.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from agent.auxiliary_client import _CodexCompletionsAdapter
|
|
|
|
|
|
def _adapter_with_recording_client(stream):
|
|
"""Build an adapter whose client records (action, thread) events.
|
|
|
|
The nested ``_client._transport._pool._connections`` shape is what
|
|
``force_close_tcp_sockets`` traverses.
|
|
"""
|
|
events = []
|
|
|
|
class _Sock:
|
|
def shutdown(self, how):
|
|
events.append(("shutdown", threading.get_ident()))
|
|
|
|
def close(self):
|
|
events.append(("sock.close", threading.get_ident()))
|
|
|
|
sock = _Sock()
|
|
|
|
class _Conn:
|
|
def __init__(self):
|
|
self._connection = self
|
|
self._network_stream = self
|
|
|
|
def get_extra_info(self, name):
|
|
return sock if name == "socket" else None
|
|
|
|
class _Client:
|
|
def __init__(self):
|
|
self._transport = SimpleNamespace(
|
|
_pool=SimpleNamespace(_connections=[_Conn()])
|
|
)
|
|
|
|
class _LeafClient:
|
|
def __init__(self):
|
|
self._client = _Client()
|
|
self.base_url = "https://chatgpt.com/backend-api/codex"
|
|
self.responses = SimpleNamespace(create=lambda **kw: stream)
|
|
|
|
def close(self):
|
|
events.append(("client.close", threading.get_ident()))
|
|
|
|
return _CodexCompletionsAdapter(_LeafClient(), "gpt-5.5"), events
|
|
|
|
|
|
class TestCodexAuxiliaryTimeoutFdOwnership:
|
|
def test_stalled_stream_timeout_shuts_down_from_timer_and_closes_from_owner(self):
|
|
"""The watchdog Timer fires on a stalled stream: it may only
|
|
shutdown(); the real close() must land on the owning thread in the
|
|
adapter's ``finally``."""
|
|
|
|
def _stalled():
|
|
deadline = time.monotonic() + 30.0
|
|
while time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
yield SimpleNamespace(type="response.in_progress")
|
|
|
|
adapter, events = _adapter_with_recording_client(_stalled())
|
|
owner_tid = threading.get_ident()
|
|
|
|
def _consume(stream, *, model, on_event):
|
|
del model
|
|
for event in stream:
|
|
on_event(event)
|
|
return SimpleNamespace(output=[], usage=None)
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._AUX_STREAM_NO_PROGRESS_TIMEOUT_SECONDS", 0.3),
|
|
patch("agent.auxiliary_client._evict_cached_client_instance"),
|
|
patch("agent.codex_runtime._consume_codex_event_stream", _consume),
|
|
pytest.raises(TimeoutError),
|
|
):
|
|
adapter.create(
|
|
messages=[{"role": "user", "content": "summarize"}],
|
|
timeout=300,
|
|
)
|
|
|
|
# Give the daemon Timer thread a beat to finish its callback.
|
|
time.sleep(0.2)
|
|
actions = [a for a, _ in events]
|
|
# Stranger thread (Timer) only shut the sockets down.
|
|
shutdown_tids = {tid for a, tid in events if a == "shutdown"}
|
|
assert "shutdown" in actions, events
|
|
assert owner_tid not in shutdown_tids, "shutdown ran on owner thread"
|
|
# close() from a stranger thread is the corruption vector — banned.
|
|
stranger_closes = [
|
|
(a, tid) for a, tid in events
|
|
if a in {"client.close", "sock.close"} and tid != owner_tid
|
|
]
|
|
assert not stranger_closes, f"stranger-thread FD release: {stranger_closes}"
|
|
# The owning thread released the FDs on unwind.
|
|
assert ("client.close", owner_tid) in events, events
|
|
|
|
def test_owner_thread_deadline_hit_closes_directly(self):
|
|
"""When the OWNING thread detects the deadline in _check_cancelled,
|
|
it may close() directly — no shutdown-only detour required."""
|
|
|
|
def _one_keepalive_then_block():
|
|
yield SimpleNamespace(type="response.in_progress")
|
|
time.sleep(1.0) # past the patched window; owner detects on next event
|
|
yield SimpleNamespace(type="response.in_progress")
|
|
|
|
adapter, events = _adapter_with_recording_client(_one_keepalive_then_block())
|
|
owner_tid = threading.get_ident()
|
|
|
|
def _consume(stream, *, model, on_event):
|
|
del model
|
|
for event in stream:
|
|
on_event(event)
|
|
return SimpleNamespace(output=[], usage=None)
|
|
|
|
# Cancel the watchdog race by making the Timer window huge relative
|
|
# to the owner's per-event check: patch the timer to never fire.
|
|
class _NeverTimer:
|
|
def __init__(self, *_a, **_k):
|
|
self.daemon = True
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def cancel(self):
|
|
pass
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._AUX_STREAM_NO_PROGRESS_TIMEOUT_SECONDS", 0.3),
|
|
patch("agent.auxiliary_client._evict_cached_client_instance"),
|
|
patch("agent.auxiliary_client.threading.Timer", _NeverTimer),
|
|
patch("agent.codex_runtime._consume_codex_event_stream", _consume),
|
|
pytest.raises(TimeoutError),
|
|
):
|
|
adapter.create(
|
|
messages=[{"role": "user", "content": "summarize"}],
|
|
timeout=300,
|
|
)
|
|
|
|
stranger = [(a, t) for a, t in events if t != owner_tid]
|
|
assert not stranger, f"non-owner activity: {stranger}"
|
|
assert ("client.close", owner_tid) in events, events
|