test_laya_lane.py 17 KB

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