Files
2026-07-13 13:12:33 +08:00

96 lines
3.2 KiB
Python

"""Regression test for skill_exec event-loop friendliness.
A skill_exec step that runs a long subprocess must NOT block the
caller's asyncio event loop. The HTTP gateway and the meta orchestrator
share that loop; a synchronous ``subprocess.run`` (the prior
implementation) would freeze ``/healthz`` / ``/control/`` while a wrapped
CLI polled a remote API for minutes.
This test reproduces the scenario with a 2-second sleep subprocess and
verifies a parallel coroutine still gets scheduled (within a 200ms slop)
while the subprocess is running.
"""
from __future__ import annotations
import asyncio
import sys
import time
from pathlib import Path
import pytest
from opensquilla.skills.meta.executors.skill_exec import run_skill_exec_step
from opensquilla.skills.meta.types import MetaStep
from opensquilla.skills.types import SkillLayer, SkillSpec
def _spec(base_dir: Path, command: str) -> SkillSpec:
return SkillSpec(
name="slow-skill",
description="test",
layer=SkillLayer.BUNDLED,
always=False,
triggers=[],
content="",
base_dir=str(base_dir),
entrypoint={"command": command, "parse": "text", "timeout": 30.0},
)
class _Loader:
def __init__(self, spec: SkillSpec) -> None:
self._spec = spec
def get_by_name(self, name: str) -> SkillSpec | None:
return self._spec if name == self._spec.name else None
@pytest.mark.asyncio
async def test_skill_exec_does_not_block_event_loop(tmp_path: Path) -> None:
"""A 2s sleep subprocess must coexist with a 50ms-tick coroutine.
If skill_exec is event-loop-friendly, the tick coroutine runs ~40
times during the 2-second subprocess. If skill_exec blocks (the bug
we fixed), the tick coroutine cannot run AT ALL during the
subprocess and finishes with ~0 ticks.
"""
sleep_script = tmp_path / "sleep_two.py"
sleep_script.write_text("import time; time.sleep(2.0)\n", encoding="utf-8")
spec = _spec(tmp_path, f"{sys.executable} {sleep_script}")
step = MetaStep(id="s1", kind="skill_exec", skill="slow-skill")
tick_count = 0
async def ticker() -> None:
nonlocal tick_count
# Stop ticking once the subprocess is clearly done.
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
tick_count += 1
await asyncio.sleep(0.05)
start = time.monotonic()
_, _ = await asyncio.gather(
run_skill_exec_step(
step,
effective_skill="slow-skill",
inputs={},
outputs={},
skill_loader=_Loader(spec),
workspace_dir=str(tmp_path),
),
ticker(),
)
wall = time.monotonic() - start
# Sanity: the test really waited ~2s (not blocked something out).
assert wall < 4.5, f"unexpectedly slow run: {wall:.2f}s"
# The ticker must have advanced many times during the 2s sleep —
# a blocking subprocess.run would have yielded 0 ticks until done.
# ~40 ticks ideal; ≥20 is a comfortable lower bound for noisy CI.
assert tick_count >= 20, (
f"event loop was blocked during skill_exec subprocess: "
f"only {tick_count} ticks in {wall:.2f}s (expected ≥20)"
)