mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
131 lines
5.8 KiB
Python
131 lines
5.8 KiB
Python
"""Real SDK outer ownership, real packaged workers; vendor replies are fixtures."""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from test_isolation import context, load
|
|
|
|
|
|
@pytest.mark.parametrize("name,mode", [("dashscope", "agent"), ("dashscope", "workflow"), ("tbox", "agent")])
|
|
@pytest.mark.parametrize("scenario", ["error", "early_close", "repeated_cancel", "success"])
|
|
def test_outer_runner_owns_vendor_until_reaped(name, mode, scenario, tmp_path, monkeypatch):
|
|
with load(name) as (_, runner):
|
|
client = importlib.import_module("pkg." + name + "_client")
|
|
transport = importlib.import_module("pkg.vendor_process")
|
|
actual_worker = Path(transport.__file__).with_name("vendor_worker.py")
|
|
if scenario == "error":
|
|
frame = {"status_code": 500, "message": "fixture"} if name == "dashscope" else {"type": "error"}
|
|
body = f" yield {frame!r}\n time.sleep(60)\n"
|
|
else:
|
|
frame = (
|
|
{"status_code": 200, "output": {"text": "x"}}
|
|
if name == "dashscope"
|
|
else {"type": "chunk", "payload": {"text": "x"}}
|
|
)
|
|
body = f" yield {frame!r}\n" if scenario == "success" else f" while True: yield {frame!r}\n"
|
|
fixture = (
|
|
"import runpy,socket,time\n"
|
|
"def deny(*a,**kw): raise AssertionError('no live network')\n"
|
|
"socket.create_connection=deny\n"
|
|
"def provider(*a,**kw):\n" + body
|
|
)
|
|
fixture += (
|
|
"from dashscope import Application\nApplication.call=provider\n"
|
|
if name == "dashscope"
|
|
else "from tboxsdk.tbox import TboxClient\nTboxClient.chat=provider\n"
|
|
)
|
|
worker = tmp_path / "vendor_fixture.py"
|
|
worker.write_text(fixture + f"runpy.run_path({str(actual_worker)!r},run_name='__main__')\n")
|
|
original = transport.vendor_stream
|
|
monkeypatch.setattr(
|
|
client, "vendor_stream", lambda payload, timeout: original(payload, timeout=timeout, worker=worker)
|
|
)
|
|
spawn = asyncio.create_subprocess_exec
|
|
processes, directories = [], []
|
|
|
|
async def run():
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
|
|
async def record(*args, **kwargs):
|
|
proc = await spawn(*args, **kwargs)
|
|
processes.append(proc)
|
|
directories.append(Path(kwargs["cwd"]))
|
|
if scenario == "repeated_cancel":
|
|
read = proc.stdout.read
|
|
|
|
async def held_cleanup(n=-1):
|
|
# Transport uses readline for frames, read only when reaping.
|
|
entered.set()
|
|
await release.wait()
|
|
return await read(n)
|
|
|
|
monkeypatch.setattr(proc.stdout, "read", held_cleanup)
|
|
return proc
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", record)
|
|
ctx = context()
|
|
ctx.config.update(
|
|
{"api-key": "fixture", "app-id": "fixture", "app-type": mode, "streaming": True, "timeout": 5}
|
|
)
|
|
runner.get_run_api = lambda ctx: SimpleNamespace()
|
|
gen = runner.invoke(ctx)
|
|
closing = None
|
|
|
|
def assert_reaped():
|
|
assert processes
|
|
for proc in processes:
|
|
assert proc.returncode is not None, "outer boundary returned before child reap"
|
|
with pytest.raises(ProcessLookupError):
|
|
os.kill(proc.pid, 0)
|
|
assert all(not directory.exists() for directory in directories)
|
|
|
|
try:
|
|
if scenario in ("error", "success"):
|
|
results = []
|
|
async for result in gen:
|
|
results.append(result)
|
|
if result.type in ("run.failed", "run.completed"):
|
|
# Check when terminal is visible, not after extra event-loop time.
|
|
assert_reaped()
|
|
assert results[-1].type == ("run.failed" if scenario == "error" else "run.completed")
|
|
else:
|
|
await anext(gen)
|
|
async with asyncio.timeout(5):
|
|
while not ctx._results.full():
|
|
await asyncio.sleep(0)
|
|
if scenario == "early_close":
|
|
await gen.aclose()
|
|
else:
|
|
closing = asyncio.create_task(gen.aclose())
|
|
await asyncio.wait_for(entered.wait(), 2)
|
|
for _ in range(3):
|
|
closing.cancel()
|
|
# Schedule a turn, not a time-based cleanup allowance.
|
|
await asyncio.sleep(0)
|
|
assert not closing.done(), "repeated cancellation released cleanup ownership"
|
|
assert ctx._api is not None
|
|
assert all(directory.exists() for directory in directories)
|
|
release.set()
|
|
await closing
|
|
assert_reaped()
|
|
assert ctx._api is None
|
|
finally:
|
|
release.set()
|
|
if closing is not None:
|
|
await asyncio.gather(closing, return_exceptions=True)
|
|
await gen.aclose()
|
|
# Failure-only teardown never shares the transport's pipe reader.
|
|
for proc in processes:
|
|
if proc.returncode is None:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
await proc.wait()
|
|
|
|
asyncio.run(run())
|