mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
* fix(jsonify): keep HTTPX TLS setup off the event loop * fix(jsonify): retain cleanup diagnostics during repeated cancellation
168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
"""Exercise Jsonify with real HTTPX initialization and no provider requests."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import ssl
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from deerflow.community.jsonify import tools
|
|
|
|
|
|
@pytest.fixture
|
|
def read_rows(monkeypatch):
|
|
monkeypatch.setattr(tools, "validate_public_http_url", lambda *args, **kwargs: None)
|
|
settings = tools._JsonifySettings(datasets={"demo": "https://jsonify.invalid/serve"})
|
|
args = tools._RowsArgs(dataset="demo")
|
|
|
|
async def read():
|
|
return await tools._read_rows(settings, args)
|
|
|
|
return read
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jsonify_loads_tls_trust_off_loop(read_rows, monkeypatch):
|
|
loop_thread = threading.get_ident()
|
|
create_default_context = ssl.create_default_context
|
|
tls_threads = []
|
|
clients = []
|
|
|
|
def create_context(*args, **kwargs):
|
|
tls_threads.append(threading.get_ident())
|
|
return create_default_context(*args, **kwargs)
|
|
|
|
async def send(self, request, **kwargs):
|
|
assert threading.get_ident() == loop_thread
|
|
assert request.url.host == "jsonify.invalid"
|
|
clients.append(self)
|
|
return httpx.Response(200, request=request, json={"rows": [{"name": "demo"}], "has_more": False})
|
|
|
|
monkeypatch.setattr(ssl, "create_default_context", create_context)
|
|
monkeypatch.setattr(httpx.AsyncClient, "send", send)
|
|
|
|
result = await read_rows()
|
|
|
|
assert result["rows"] == [{"name": "demo"}]
|
|
assert tls_threads and loop_thread not in tls_threads
|
|
assert len(clients) == 1
|
|
assert clients[0].is_closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jsonify_closes_client_if_cancelled_during_setup(read_rows, monkeypatch):
|
|
loop = asyncio.get_running_loop()
|
|
loop_thread = threading.get_ident()
|
|
started = asyncio.Event()
|
|
finished = asyncio.Event()
|
|
release = threading.Event()
|
|
real_client = httpx.AsyncClient
|
|
clients = []
|
|
|
|
def construct(**kwargs):
|
|
assert threading.get_ident() != loop_thread, "the constructor must not run on the event loop"
|
|
loop.call_soon_threadsafe(started.set)
|
|
try:
|
|
assert release.wait(10), "the constructor was never released"
|
|
client = real_client(**kwargs)
|
|
clients.append(client)
|
|
return client
|
|
finally:
|
|
loop.call_soon_threadsafe(finished.set)
|
|
|
|
async def unexpected_send(self, request, **kwargs):
|
|
pytest.fail("a cancelled construction must not send a request")
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", construct)
|
|
monkeypatch.setattr(real_client, "send", unexpected_send)
|
|
pending = asyncio.create_task(read_rows())
|
|
start_waiter = asyncio.create_task(started.wait())
|
|
try:
|
|
done, _ = await asyncio.wait({pending, start_waiter}, timeout=10, return_when=asyncio.FIRST_COMPLETED)
|
|
if pending in done:
|
|
await pending
|
|
assert start_waiter in done, "the constructor never started"
|
|
|
|
pending.cancel()
|
|
await asyncio.sleep(0) # Deliver cancellation while construction is still gated.
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await pending
|
|
await asyncio.wait_for(finished.wait(), timeout=10)
|
|
assert len(clients) == 1
|
|
assert clients[0].is_closed
|
|
finally:
|
|
release.set()
|
|
pending.cancel()
|
|
start_waiter.cancel()
|
|
await asyncio.gather(pending, start_waiter, return_exceptions=True)
|
|
if started.is_set():
|
|
await asyncio.wait_for(finished.wait(), timeout=10)
|
|
for client in clients:
|
|
if not client.is_closed:
|
|
await client.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure_phase", ["construction", "close"])
|
|
async def test_jsonify_logs_failures_during_repeated_cancellation(read_rows, monkeypatch, caplog, failure_phase):
|
|
loop = asyncio.get_running_loop()
|
|
loop_thread = threading.get_ident()
|
|
started, finished = asyncio.Event(), asyncio.Event()
|
|
release = threading.Event()
|
|
close_threads = []
|
|
secret = "synthetic-sensitive-value"
|
|
caplog.set_level(logging.ERROR)
|
|
|
|
async def close():
|
|
close_threads.append(threading.get_ident())
|
|
raise RuntimeError(secret)
|
|
|
|
def construct(**kwargs):
|
|
assert threading.get_ident() != loop_thread
|
|
loop.call_soon_threadsafe(started.set)
|
|
try:
|
|
assert release.wait(10), "the constructor was never released"
|
|
if failure_phase == "construction":
|
|
raise RuntimeError(secret)
|
|
return SimpleNamespace(aclose=close)
|
|
finally:
|
|
loop.call_soon_threadsafe(finished.set)
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", construct)
|
|
pending = asyncio.create_task(read_rows())
|
|
start_waiter = asyncio.create_task(started.wait())
|
|
try:
|
|
done, _ = await asyncio.wait({pending, start_waiter}, timeout=10, return_when=asyncio.FIRST_COMPLETED)
|
|
if pending in done:
|
|
await pending
|
|
assert start_waiter in done, "the constructor never started"
|
|
|
|
pending.cancel()
|
|
await asyncio.sleep(0) # Enter cleanup while construction is still gated.
|
|
pending.cancel()
|
|
await asyncio.sleep(0) # Cancel the drain before its worker fails.
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await pending
|
|
await asyncio.wait_for(finished.wait(), timeout=10)
|
|
|
|
assert close_threads == ([loop_thread] if failure_phase == "close" else [])
|
|
errors = [record for record in caplog.records if record.levelno == logging.ERROR]
|
|
assert len(errors) == 1
|
|
assert errors[0].levelno == logging.ERROR
|
|
assert "RuntimeError" in errors[0].getMessage()
|
|
assert secret not in errors[0].getMessage()
|
|
finally:
|
|
release.set()
|
|
pending.cancel()
|
|
start_waiter.cancel()
|
|
await asyncio.gather(pending, start_waiter, return_exceptions=True)
|
|
if started.is_set():
|
|
await asyncio.wait_for(finished.wait(), timeout=10)
|