test_laya_lane.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. """Tests for the in-process Laya triage lane (todos 5, 7, 8).
  2. All lane tests inject a fake agent factory; the real `laya` package is never
  3. required. The single measurement test (todo 5) is decorated with
  4. `pytest.importorskip("laya")` so the default suite skips it green.
  5. """
  6. import sys
  7. import threading
  8. import time
  9. from types import SimpleNamespace
  10. import pytest
  11. from stt_daemon.assistant.laya_lane import LayaLane, device_or_none
  12. LAYA_PIN_SHA = "970dc8c5f63d7b886a68409493f37d569424f933"
  13. def make_cfg(**overrides):
  14. cfg = {
  15. "device": "",
  16. "preload": ["multilingual"],
  17. "default_checkpoint": "multilingual",
  18. }
  19. cfg.update(overrides)
  20. return SimpleNamespace(**cfg)
  21. class FakeRouter:
  22. """Fake laya Router: records ctor kwargs, preload list, predict calls."""
  23. def __init__(self, calls, **kwargs):
  24. self._calls = calls
  25. self.ctor_kwargs = kwargs
  26. self.preload_names = None
  27. self.predict_log = []
  28. self.predict_impl = None
  29. self.fail_warmup = False
  30. self.current = 0
  31. self.max_concurrent = 0
  32. def preload(self, names):
  33. self.preload_names = list(names)
  34. return self
  35. def predict(self, state, questions, model=None, lang=None, **kwargs):
  36. self._calls["count"] += 1
  37. self.current += 1
  38. self.max_concurrent = max(self.max_concurrent, self.current)
  39. try:
  40. self.predict_log.append(
  41. {"state": state, "questions": questions, "model": model, "lang": lang}
  42. )
  43. if "_warmup" in questions:
  44. # loader warmup: never gated, returns immediately
  45. if self.fail_warmup:
  46. raise RuntimeError("warmup boom")
  47. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  48. if self.predict_impl is not None:
  49. return self.predict_impl(state, questions, model=model, lang=lang)
  50. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  51. finally:
  52. self.current -= 1
  53. class FakeFactory:
  54. """Callable recording ctor kwargs and spawning FakeRouter instances."""
  55. def __init__(self, calls, predict_impl=None):
  56. self._calls = calls
  57. self._predict_impl = predict_impl
  58. self.routers = []
  59. def __call__(self, **kwargs):
  60. router = FakeRouter(self._calls, **kwargs)
  61. router.predict_impl = self._predict_impl
  62. self.routers.append(router)
  63. self._calls["ctor_kwargs"] = kwargs
  64. return router
  65. def make_factory(calls, predict_impl=None):
  66. return FakeFactory(calls, predict_impl=predict_impl)
  67. def wait_until(predicate, timeout=5.0):
  68. deadline = time.monotonic() + timeout
  69. while time.monotonic() < deadline:
  70. if predicate():
  71. return True
  72. time.sleep(0.005)
  73. return predicate()
  74. def start_ready_lane(calls=None, **kwargs):
  75. """Start a lane whose loader succeeds immediately; wait for ready."""
  76. calls = calls if calls is not None else {"count": 0}
  77. results = []
  78. states = []
  79. factory = make_factory(calls)
  80. lane = LayaLane(
  81. make_cfg(),
  82. agent_factory=factory,
  83. on_result=lambda gen, payload: results.append((gen, payload)),
  84. on_state=lambda s, r: states.append((s, r)),
  85. **kwargs,
  86. )
  87. lane.start()
  88. assert wait_until(lane.ready), "lane did not become ready"
  89. return lane, factory, results, states
  90. # -- todo 7: lazy import + loader --------------------------------------
  91. def test_no_top_level_laya_import():
  92. assert "laya" not in sys.modules
  93. assert "torch" not in sys.modules
  94. def test_device_or_none():
  95. assert device_or_none("") is None
  96. assert device_or_none("cpu") == "cpu"
  97. assert device_or_none("cuda") == "cuda"
  98. def test_loader_builds_router_preloads_and_warms_up():
  99. calls = {"count": 0}
  100. lane, factory, results, states = start_ready_lane(calls)
  101. try:
  102. assert lane.state() == ("ok", "")
  103. assert states == [("ok", "")]
  104. assert calls["ctor_kwargs"] == {"default": "multilingual", "device": None}
  105. router = factory.routers[0]
  106. assert router.preload_names == ["multilingual"]
  107. # one throwaway warmup predict with model="multilingual"
  108. assert len(router.predict_log) == 1
  109. warmup = router.predict_log[0]
  110. assert warmup["model"] == "multilingual"
  111. assert warmup["questions"] == {
  112. "_warmup": {"type": "noul", "instructions": "warmup"}
  113. }
  114. assert results == [] # warmup never emits on_result
  115. finally:
  116. lane.stop()
  117. def test_loader_device_passthrough():
  118. calls = {"count": 0}
  119. factory = make_factory(calls)
  120. lane = LayaLane(make_cfg(device="cpu"), agent_factory=factory)
  121. lane.start()
  122. try:
  123. assert wait_until(lane.ready)
  124. assert calls["ctor_kwargs"] == {"default": "multilingual", "device": "cpu"}
  125. finally:
  126. lane.stop()
  127. def test_loader_import_error_gives_laya_missing():
  128. def factory(**kwargs):
  129. raise ImportError("No module named 'laya'")
  130. states = []
  131. lane = LayaLane(make_cfg(), agent_factory=factory,
  132. on_state=lambda s, r: states.append((s, r)))
  133. lane.start() # must not raise
  134. try:
  135. assert wait_until(lambda: lane.state()[0] == "laya_missing")
  136. assert lane.ready() is False
  137. status, hint = lane.state()
  138. assert status == "laya_missing"
  139. assert "uv sync --extra triage" in hint
  140. assert states == [("laya_missing", hint)]
  141. finally:
  142. lane.stop()
  143. def test_loader_generic_exception_gives_error():
  144. def factory(**kwargs):
  145. raise RuntimeError("CUDA OOM")
  146. lane = LayaLane(make_cfg(), agent_factory=factory)
  147. lane.start() # must not raise
  148. try:
  149. assert wait_until(lambda: lane.state()[0] == "error")
  150. assert lane.ready() is False
  151. assert "CUDA OOM" in lane.state()[1]
  152. finally:
  153. lane.stop()
  154. def test_loader_warmup_crash_gives_error():
  155. calls = {"count": 0}
  156. real_factory = make_factory(calls)
  157. def arming_factory(**kwargs):
  158. router = real_factory(**kwargs)
  159. router.fail_warmup = True
  160. return router
  161. lane = LayaLane(make_cfg(), agent_factory=arming_factory)
  162. lane.start()
  163. try:
  164. assert wait_until(lambda: lane.state()[0] == "error")
  165. assert lane.ready() is False
  166. finally:
  167. lane.stop()
  168. def test_start_never_raises_even_when_spawn_fails(monkeypatch):
  169. calls = {"count": 0}
  170. lane = LayaLane(make_cfg(), agent_factory=make_factory(calls))
  171. def boom(*args, **kwargs):
  172. raise RuntimeError("no threads")
  173. monkeypatch.setattr(threading, "Thread", boom)
  174. lane.start() # must not raise
  175. def test_submit_false_when_failed():
  176. def factory(**kwargs):
  177. raise ImportError("No module named 'laya'")
  178. lane = LayaLane(make_cfg(), agent_factory=factory)
  179. lane.start()
  180. try:
  181. assert wait_until(lambda: lane.state()[0] == "laya_missing")
  182. assert lane.submit("s", {}, None, 1) is False
  183. finally:
  184. lane.stop()
  185. def test_submit_false_when_error():
  186. def factory(**kwargs):
  187. raise RuntimeError("hub down")
  188. lane = LayaLane(make_cfg(), agent_factory=factory)
  189. lane.start()
  190. try:
  191. assert wait_until(lambda: lane.state()[0] == "error")
  192. assert lane.submit("s", {}, None, 1) is False
  193. finally:
  194. lane.stop()
  195. # -- todo 8: single-flight / coalesce / shutdown ------------------------
  196. def test_pre_ready_submit_drops_silently():
  197. calls = {"count": 0}
  198. results = []
  199. gate = threading.Event()
  200. def predict_impl(state, questions, model=None, lang=None):
  201. gate.wait(timeout=5.0)
  202. return {"answers": {}}
  203. factory = make_factory(calls, predict_impl=predict_impl)
  204. lane = LayaLane(make_cfg(), agent_factory=factory,
  205. on_result=lambda gen, payload: results.append((gen, payload)))
  206. # submit before start(): not ready -> False, nothing recorded
  207. assert lane.submit("early", {"q": 1}, None, 0) is False
  208. assert calls["count"] == 0
  209. assert results == []
  210. lane.stop()
  211. assert calls["count"] == 0
  212. assert results == []
  213. def test_submit_newest_wins_while_blocked():
  214. calls = {"count": 0}
  215. entered = threading.Event()
  216. release = threading.Event()
  217. first = {"done": False}
  218. def predict_impl(state, questions, model=None, lang=None):
  219. if not first["done"]:
  220. first["done"] = True
  221. entered.set()
  222. assert release.wait(timeout=5.0)
  223. return {"answers": {"gen": 0}}
  224. return {"answers": {"state": state}}
  225. factory = make_factory(calls, predict_impl=predict_impl)
  226. results = []
  227. done = threading.Event()
  228. def on_result(gen, payload):
  229. results.append((gen, payload))
  230. if len(results) >= 2:
  231. done.set()
  232. lane = LayaLane(
  233. make_cfg(), agent_factory=factory, on_result=on_result,
  234. )
  235. lane.start()
  236. try:
  237. assert wait_until(lane.ready)
  238. router = factory.routers[0]
  239. router.predict_log.clear()
  240. calls["count"] = 0
  241. assert lane.submit("state-0", {"q": 0}, None, 0) is True
  242. assert entered.wait(timeout=5.0)
  243. # 10 rapid submits while the first predict is blocked
  244. for i in range(1, 11):
  245. assert lane.submit("state-%d" % i, {"q": i}, "es", i) is True
  246. release.set()
  247. assert wait_until(lambda: len(results) >= 2, timeout=5.0)
  248. # exactly one coalesced predict for the newest window
  249. assert len(router.predict_log) == 2
  250. assert router.predict_log[0]["state"] == "state-0"
  251. assert router.predict_log[1]["state"] == "state-10"
  252. assert router.predict_log[1]["questions"] == {"q": 10}
  253. assert router.predict_log[1]["lang"] == "es"
  254. assert router.predict_log[1]["model"] == "multilingual"
  255. assert [gen for gen, _ in results] == [0, 10]
  256. finally:
  257. lane.stop()
  258. def test_max_concurrent_predicts_is_one():
  259. calls = {"count": 0}
  260. started = threading.Barrier(2, timeout=5.0)
  261. def predict_impl(state, questions, model=None, lang=None):
  262. try:
  263. started.wait(timeout=5.0)
  264. except threading.BrokenBarrierError:
  265. pass
  266. time.sleep(0.02)
  267. return {"answers": {}}
  268. factory = make_factory(calls, predict_impl=predict_impl)
  269. results = []
  270. results_lock = threading.Lock()
  271. def on_hammer_result(gen, payload):
  272. with results_lock:
  273. results.append(gen)
  274. lane = LayaLane(
  275. make_cfg(), agent_factory=factory, on_result=on_hammer_result,
  276. )
  277. lane.start()
  278. try:
  279. assert wait_until(lane.ready)
  280. router = factory.routers[0]
  281. def hammer(n):
  282. for i in range(n):
  283. lane.submit("s-%d-%d" % (n, i), {}, None, n * 100 + i)
  284. threads = [threading.Thread(target=hammer, args=(n,)) for n in range(4)]
  285. for t in threads:
  286. t.start()
  287. for t in threads:
  288. t.join()
  289. assert wait_until(lambda: calls["count"] >= 2, timeout=5.0)
  290. # drain: wait until no more predicts happen
  291. before = calls["count"]
  292. time.sleep(0.15)
  293. # single worker thread => concurrency counter never exceeded 1
  294. assert router.max_concurrent == 1
  295. assert calls["count"] >= before
  296. finally:
  297. lane.stop()
  298. def test_on_result_exactly_once_per_predict_none_on_exception():
  299. calls = {"count": 0}
  300. def predict_impl(state, questions, model=None, lang=None):
  301. if state == "bad":
  302. raise ValueError("infer failed")
  303. return {"answers": {"ok": True}, "model": model}
  304. factory = make_factory(calls, predict_impl=predict_impl)
  305. results = []
  306. lane = LayaLane(
  307. make_cfg(), agent_factory=factory,
  308. on_result=lambda gen, payload: results.append((gen, payload)),
  309. )
  310. lane.start()
  311. try:
  312. assert wait_until(lane.ready)
  313. router = factory.routers[0]
  314. router.predict_log.clear()
  315. assert lane.submit("good", {}, None, 7) is True
  316. assert wait_until(lambda: len(results) >= 1, timeout=5.0)
  317. assert lane.submit("bad", {}, None, 8) is True
  318. assert wait_until(lambda: len(results) >= 2, timeout=5.0)
  319. assert results[0][0] == 7
  320. assert results[0][1]["answers"] == {"ok": True}
  321. assert results[1] == (8, None)
  322. assert len(router.predict_log) == 2
  323. finally:
  324. lane.stop()
  325. def test_drop_pending_clears_slot():
  326. calls = {"count": 0}
  327. entered = threading.Event()
  328. release = threading.Event()
  329. gate_calls = {"n": 0}
  330. def predict_impl(state, questions, model=None, lang=None):
  331. gate_calls["n"] += 1
  332. if gate_calls["n"] == 1:
  333. entered.set()
  334. assert release.wait(timeout=5.0)
  335. return {"answers": {"state": state}}
  336. factory = make_factory(calls, predict_impl=predict_impl)
  337. results = []
  338. lane = LayaLane(
  339. make_cfg(), agent_factory=factory,
  340. on_result=lambda gen, payload: results.append((gen, payload)),
  341. )
  342. lane.start()
  343. try:
  344. assert wait_until(lane.ready)
  345. assert lane.submit("first", {}, None, 1) is True
  346. assert entered.wait(timeout=5.0)
  347. assert lane.submit("dropped", {}, None, 2) is True
  348. lane.drop_pending()
  349. release.set()
  350. assert wait_until(lambda: len(results) >= 1, timeout=5.0)
  351. time.sleep(0.1) # allow any stray coalesced predict to (not) run
  352. states = [entry["state"] for entry in factory.routers[0].predict_log[1:]]
  353. assert "dropped" not in states
  354. assert [gen for gen, _ in results] == [1]
  355. finally:
  356. lane.stop()
  357. def test_stop_during_blocked_predict_bounded_and_silent():
  358. calls = {"count": 0}
  359. entered = threading.Event()
  360. release = threading.Event()
  361. def predict_impl(state, questions, model=None, lang=None):
  362. entered.set()
  363. release.wait(timeout=10.0)
  364. return {"answers": {}}
  365. factory = make_factory(calls, predict_impl=predict_impl)
  366. results = []
  367. lane = LayaLane(
  368. make_cfg(), agent_factory=factory,
  369. on_result=lambda gen, payload: results.append((gen, payload)),
  370. )
  371. lane.start()
  372. assert wait_until(lane.ready)
  373. router = factory.routers[0]
  374. router.predict_log.clear()
  375. calls["count"] = 0 # exclude the loader warmup predict
  376. assert lane.submit("blocked", {}, None, 3) is True
  377. assert entered.wait(timeout=5.0)
  378. start = time.monotonic()
  379. lane.stop()
  380. elapsed = time.monotonic() - start
  381. assert elapsed < 2.5 # 1.0 s join bound per thread (loader already done)
  382. assert lane.submit("after", {}, None, 4) is False
  383. release.set()
  384. time.sleep(0.3) # let the stranded predict finish if it can
  385. assert results == [] # no callback fires after stop() returns
  386. assert calls["count"] == 1
  387. def test_repeated_submit_drop_stop_leaves_no_thread_alive():
  388. before = set(threading.enumerate())
  389. for _ in range(3):
  390. calls = {"count": 0}
  391. lane = LayaLane(make_cfg(), agent_factory=make_factory(calls))
  392. lane.start()
  393. assert wait_until(lane.ready)
  394. for i in range(5):
  395. lane.submit("s-%d" % i, {}, None, i)
  396. lane.drop_pending()
  397. lane.stop()
  398. lane.stop() # idempotent
  399. # give daemon threads a beat to exit, then check none linger
  400. assert wait_until(
  401. lambda: not ({t.name for t in threading.enumerate() if t.is_alive()}
  402. & {"laya-loader", "laya-worker"}),
  403. timeout=5.0,
  404. )
  405. lingering = [t for t in threading.enumerate()
  406. if t.is_alive() and t.name in ("laya-loader", "laya-worker")
  407. and t not in before]
  408. assert lingering == []
  409. # -- todo 5: multilingual pin + lang + resource measurement ------------
  410. def test_multilingual_pin_lang_and_resources():
  411. laya = pytest.importorskip("laya")
  412. import resource
  413. router = laya.Router(default="multilingual")
  414. assert router.default == "multilingual"
  415. questions = {"_probe": {"type": "noul", "instructions": "warmup probe"}}
  416. rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
  417. start = time.monotonic()
  418. result = router.predict("hola mundo", questions, model="multilingual", lang="es")
  419. latency_s = time.monotonic() - start
  420. rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
  421. assert set(result) >= {"model", "answers", "usage", "routing"}
  422. assert "multilingual" in str(result["routing"])
  423. assert "_probe" in result["answers"]
  424. assert latency_s >= 0.0
  425. assert isinstance(router.loaded, list) and "multilingual" in router.loaded
  426. assert rss_after >= 0 and rss_before >= 0
  427. # pin verified against the todo 2 laya revision
  428. assert LAYA_PIN_SHA == "970dc8c5f63d7b886a68409493f37d569424f933"
  429. assert laya.__version__ == "0.3.20"