test_camera_fanout.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. """Unit tests for the MJPEG fan-out broadcaster (#1089).
  2. These tests do not touch ffmpeg or any printer — they drive a fake upstream
  3. generator and assert subscriber/pump lifecycle behaviour.
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. from collections.abc import AsyncGenerator
  8. import pytest
  9. from backend.app.services import camera_fanout
  10. from backend.app.services.camera_fanout import (
  11. MjpegBroadcaster,
  12. get_or_create_broadcaster,
  13. iter_subscriber,
  14. shutdown_all_broadcasters,
  15. shutdown_broadcaster,
  16. )
  17. pytestmark = pytest.mark.asyncio
  18. # Speed up grace-window tests so the suite stays fast. The default 5s grace
  19. # is overkill for unit tests; we patch it down to a few ms.
  20. @pytest.fixture(autouse=True)
  21. def _short_grace(monkeypatch):
  22. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.05)
  23. @pytest.fixture(autouse=True)
  24. async def _clean_registry():
  25. """Reset the global broadcaster registry between tests."""
  26. await shutdown_all_broadcasters()
  27. yield
  28. await shutdown_all_broadcasters()
  29. def _make_factory(
  30. chunks: list[bytes],
  31. *,
  32. delay: float = 0.0,
  33. pump_started: asyncio.Event | None = None,
  34. pump_count: list[int] | None = None,
  35. ):
  36. """Build an upstream factory that yields a fixed list of chunks."""
  37. async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
  38. if pump_started is not None:
  39. pump_started.set()
  40. if pump_count is not None:
  41. pump_count[0] += 1
  42. for chunk in chunks:
  43. if disconnect.is_set():
  44. return
  45. if delay:
  46. try:
  47. await asyncio.wait_for(disconnect.wait(), timeout=delay)
  48. return
  49. except asyncio.TimeoutError:
  50. pass
  51. yield chunk
  52. return factory
  53. # ---------------------------------------------------------------------------
  54. # Single subscriber
  55. # ---------------------------------------------------------------------------
  56. async def test_single_subscriber_receives_all_frames():
  57. bc = MjpegBroadcaster("p1", _make_factory([b"a", b"b", b"c"], delay=0.005))
  58. queue = await bc.subscribe()
  59. received = []
  60. for _ in range(3):
  61. received.append(await asyncio.wait_for(queue.get(), timeout=1.0))
  62. assert received == [b"a", b"b", b"c"]
  63. await bc.force_shutdown()
  64. # ---------------------------------------------------------------------------
  65. # Multiple subscribers share one upstream
  66. # ---------------------------------------------------------------------------
  67. async def test_multiple_subscribers_share_single_upstream():
  68. pump_count = [0]
  69. bc = MjpegBroadcaster(
  70. "p1",
  71. _make_factory([b"f1", b"f2", b"f3"], delay=0.01, pump_count=pump_count),
  72. )
  73. q1 = await bc.subscribe()
  74. q2 = await bc.subscribe()
  75. q3 = await bc.subscribe()
  76. # Each subscriber must receive each frame exactly once.
  77. for q in (q1, q2, q3):
  78. received = []
  79. for _ in range(3):
  80. received.append(await asyncio.wait_for(q.get(), timeout=1.0))
  81. assert received == [b"f1", b"f2", b"f3"]
  82. # Only ONE upstream pump ever ran — that is the entire point of the bug fix.
  83. assert pump_count[0] == 1
  84. await bc.force_shutdown()
  85. # ---------------------------------------------------------------------------
  86. # Late subscribers are primed with the last frame (#2521)
  87. # ---------------------------------------------------------------------------
  88. async def test_late_subscriber_primed_with_last_frame():
  89. """A viewer that joins after the stream is running must receive the most
  90. recent frame immediately, not wait for the next upstream frame. On slow
  91. chamber-image cams that wait looked like a permanent black screen (#2521).
  92. """
  93. async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
  94. yield b"first"
  95. await disconnect.wait() # then hold the stream open, no further frames
  96. bc = MjpegBroadcaster("p1", factory)
  97. q1 = await bc.subscribe()
  98. # First subscriber consumes the frame; this also guarantees the pump has
  99. # recorded it as the last chunk.
  100. assert await asyncio.wait_for(q1.get(), timeout=1.0) == b"first"
  101. # Late joiner is handed that frame at once, even though no new frame is coming.
  102. q2 = await bc.subscribe()
  103. assert await asyncio.wait_for(q2.get(), timeout=0.2) == b"first"
  104. await bc.force_shutdown()
  105. async def test_first_subscriber_not_primed():
  106. """The very first subscriber has no prior frame to be primed with — its
  107. queue starts empty and it triggers the upstream connect.
  108. """
  109. async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
  110. await disconnect.wait() # never produces a frame
  111. yield b"never" # pragma: no cover
  112. bc = MjpegBroadcaster("p1", factory)
  113. q1 = await bc.subscribe()
  114. await asyncio.sleep(0) # let the pump start
  115. assert q1.empty()
  116. await bc.force_shutdown()
  117. # ---------------------------------------------------------------------------
  118. # Slow subscriber should not block fast subscribers
  119. # ---------------------------------------------------------------------------
  120. async def test_slow_subscriber_does_not_block_others():
  121. # Generate more frames than the queue depth so a non-draining queue is
  122. # guaranteed to fill up.
  123. chunks = [bytes([i % 256]) for i in range(50)]
  124. bc = MjpegBroadcaster("p1", _make_factory(chunks, delay=0.001))
  125. slow = await bc.subscribe()
  126. fast = await bc.subscribe()
  127. # Drain `fast` quickly; never read from `slow`. The fast subscriber must
  128. # still get every frame even though `slow` is wedged.
  129. received_fast = []
  130. for _ in range(50):
  131. received_fast.append(await asyncio.wait_for(fast.get(), timeout=2.0))
  132. assert received_fast == chunks
  133. # Slow subscriber's queue should be at most _SUBSCRIBER_QUEUE_SIZE — older
  134. # frames were dropped, not stuffed indefinitely.
  135. assert slow.qsize() <= camera_fanout._SUBSCRIBER_QUEUE_SIZE
  136. await bc.force_shutdown()
  137. # ---------------------------------------------------------------------------
  138. # Last-subscriber-leaves grace window
  139. # ---------------------------------------------------------------------------
  140. async def test_pump_torn_down_after_last_subscriber_leaves(monkeypatch):
  141. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.05)
  142. pump_count = [0]
  143. # Long upstream so we know it's still running until disconnect signals it.
  144. bc = MjpegBroadcaster(
  145. "p1",
  146. _make_factory([b"x"] * 1000, delay=0.05, pump_count=pump_count),
  147. )
  148. queue = await bc.subscribe()
  149. # Read a couple of frames.
  150. await asyncio.wait_for(queue.get(), timeout=1.0)
  151. await bc.unsubscribe(queue)
  152. # Wait for grace window to elapse + a hair more.
  153. await asyncio.sleep(0.2)
  154. assert bc.subscriber_count == 0
  155. assert bc.stopped is True
  156. assert pump_count[0] == 1
  157. async def test_grace_window_cancelled_on_rejoin(monkeypatch):
  158. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.1)
  159. pump_count = [0]
  160. bc = MjpegBroadcaster(
  161. "p1",
  162. _make_factory([b"x"] * 1000, delay=0.02, pump_count=pump_count),
  163. )
  164. q1 = await bc.subscribe()
  165. await asyncio.wait_for(q1.get(), timeout=1.0)
  166. await bc.unsubscribe(q1)
  167. # Rejoin BEFORE grace expires — pump should keep running.
  168. await asyncio.sleep(0.02)
  169. q2 = await bc.subscribe()
  170. # Settle past the original grace deadline.
  171. await asyncio.sleep(0.2)
  172. # Pump still alive, only one upstream connection ever opened.
  173. assert bc.stopped is False
  174. assert pump_count[0] == 1
  175. # And the second subscriber is still receiving frames.
  176. await asyncio.wait_for(q2.get(), timeout=1.0)
  177. await bc.force_shutdown()
  178. # ---------------------------------------------------------------------------
  179. # Force shutdown wakes subscribers
  180. # ---------------------------------------------------------------------------
  181. async def test_force_shutdown_signals_subscribers():
  182. bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.05))
  183. queue = await bc.subscribe()
  184. await asyncio.wait_for(queue.get(), timeout=1.0)
  185. await bc.force_shutdown()
  186. # Subscriber's queue should contain the upstream-gone sentinel (or be
  187. # drained); either way a get() must complete promptly.
  188. sentinel = await asyncio.wait_for(queue.get(), timeout=1.0)
  189. assert sentinel == camera_fanout._UPSTREAM_GONE
  190. assert bc.stopped is True
  191. # ---------------------------------------------------------------------------
  192. # iter_subscriber helper exits cleanly on upstream-gone and disconnect
  193. # ---------------------------------------------------------------------------
  194. async def test_iter_subscriber_exits_on_upstream_gone():
  195. bc = MjpegBroadcaster("p1", _make_factory([b"a", b"b"], delay=0.005))
  196. queue = await bc.subscribe()
  197. received = []
  198. async for chunk in iter_subscriber(bc, queue):
  199. received.append(chunk)
  200. # Pump exited after yielding two chunks; iter_subscriber must return.
  201. assert received == [b"a", b"b"]
  202. # Helper unsubscribed us on the way out.
  203. assert bc.subscriber_count == 0
  204. async def test_iter_subscriber_exits_on_client_disconnect():
  205. bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.02))
  206. queue = await bc.subscribe()
  207. seen = 0
  208. async def is_disconnected() -> bool:
  209. return seen >= 2 # Pretend the client left after 2 frames.
  210. async for _chunk in iter_subscriber(bc, queue, is_disconnected=is_disconnected):
  211. seen += 1
  212. if seen >= 5: # Defensive cap so a buggy iterator can't run forever.
  213. break
  214. assert seen == 2
  215. assert bc.subscriber_count == 0
  216. await bc.force_shutdown()
  217. # ---------------------------------------------------------------------------
  218. # Registry: stopped broadcasters get replaced
  219. # ---------------------------------------------------------------------------
  220. async def test_registry_replaces_stopped_broadcaster():
  221. factory_a = _make_factory([b"a"] * 1000, delay=0.02)
  222. factory_b = _make_factory([b"b"] * 1000, delay=0.02)
  223. bc1 = await get_or_create_broadcaster("p1", factory_a)
  224. q1 = await bc1.subscribe()
  225. await asyncio.wait_for(q1.get(), timeout=1.0)
  226. await shutdown_broadcaster("p1")
  227. assert bc1.stopped is True
  228. # New subscription must get a fresh broadcaster.
  229. bc2 = await get_or_create_broadcaster("p1", factory_b)
  230. assert bc2 is not bc1
  231. q2 = await bc2.subscribe()
  232. chunk = await asyncio.wait_for(q2.get(), timeout=1.0)
  233. assert chunk == b"b"
  234. await shutdown_broadcaster("p1")
  235. # ---------------------------------------------------------------------------
  236. # Audit findings: subscribe-after-grace-stops contract + unsubscribe count
  237. # ---------------------------------------------------------------------------
  238. async def test_subscribe_to_stopped_raises_so_route_can_retry():
  239. """Contract: subscribe() raises RuntimeError when called on a stopped
  240. broadcaster. The route relies on this signal to re-fetch the registry
  241. entry (which will then mint a fresh broadcaster) instead of subscribing
  242. to a corpse.
  243. """
  244. bc = MjpegBroadcaster("p1", _make_factory([b"x"], delay=0.005))
  245. await bc.force_shutdown()
  246. assert bc.stopped is True
  247. with pytest.raises(RuntimeError):
  248. await bc.subscribe()
  249. async def test_unsubscribe_returns_remaining_count_atomically():
  250. """Two subscribers leaving simultaneously must report distinct remaining
  251. counts (1 then 0), not both report 0 due to a race between unsubscribe
  252. and reading subscriber_count after the fact.
  253. """
  254. bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.05))
  255. q1 = await bc.subscribe()
  256. q2 = await bc.subscribe()
  257. # Run both unsubscribes concurrently. Each should return its own
  258. # post-removal count.
  259. counts = await asyncio.gather(bc.unsubscribe(q1), bc.unsubscribe(q2))
  260. assert sorted(counts) == [0, 1], f"expected one unsubscribe to see 1 remaining and the other to see 0, got {counts}"
  261. await bc.force_shutdown()
  262. async def test_unsubscribe_idempotent_returns_current_count():
  263. """Double-unsubscribe (e.g. shutdown raced with iter_subscriber finally)
  264. must not corrupt state; second call returns whatever the count is now.
  265. """
  266. bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.05))
  267. q1 = await bc.subscribe()
  268. await bc.subscribe() # q2 stays subscribed; we only care about removal of q1
  269. first = await bc.unsubscribe(q1)
  270. again = await bc.unsubscribe(q1) # already gone
  271. assert first == 1
  272. assert again == 1 # q2 is still there
  273. await bc.force_shutdown()
  274. async def test_force_shutdown_then_subscribe_via_registry_works():
  275. """Simulates the route's retry path: a viewer calls subscribe(), gets
  276. RuntimeError, calls get_or_create_broadcaster again, and successfully
  277. subscribes to the fresh broadcaster.
  278. """
  279. factory = _make_factory([b"hello"] * 1000, delay=0.02)
  280. bc1 = await get_or_create_broadcaster("p1", factory)
  281. # Mark the registered broadcaster stopped to simulate the grace teardown
  282. # winning the race against a new subscriber.
  283. await bc1.force_shutdown()
  284. # First subscribe attempt would raise on bc1; the registry replaces it.
  285. bc2 = await get_or_create_broadcaster("p1", factory)
  286. assert bc2 is not bc1
  287. queue = await bc2.subscribe()
  288. chunk = await asyncio.wait_for(queue.get(), timeout=1.0)
  289. assert chunk == b"hello"
  290. await shutdown_broadcaster("p1")
  291. # ---------------------------------------------------------------------------
  292. # Teardown barrier: replacement waits for the prior upstream socket to close
  293. # ---------------------------------------------------------------------------
  294. async def test_wait_until_torn_down_completes_after_force_shutdown():
  295. bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.05))
  296. await bc.subscribe()
  297. await bc.force_shutdown()
  298. # Fully torn down → the barrier returns promptly.
  299. await asyncio.wait_for(bc.wait_until_torn_down(), timeout=1.0)
  300. async def test_successor_pump_waits_for_predecessor_socket_close():
  301. """A replacement broadcaster's pump must not dial the printer until the
  302. displaced (stopped) one's socket has finished closing — otherwise a
  303. single-connection printer briefly sees two sockets and strands frames on
  304. the orphaned one (#2521). Guarding at the pump (not at get_or_create) keeps
  305. it correct even when concurrent viewers race to replace the same corpse.
  306. Drive the mid-teardown state directly so the test is deterministic.
  307. """
  308. factory = _make_factory([b"x"] * 1000, delay=0.02)
  309. bc1 = MjpegBroadcaster("p1", factory)
  310. # Register it and simulate "grace fired: stopped, but socket not yet closed".
  311. camera_fanout._broadcasters["p1"] = bc1
  312. bc1._stopped = True # noqa: SLF001 — white-box: mid-teardown snapshot
  313. assert not bc1._teardown_complete.is_set() # noqa: SLF001
  314. # get_or_create returns immediately with the successor chained to bc1.
  315. bc2 = await get_or_create_broadcaster("p1", factory)
  316. assert bc2 is not bc1
  317. # Subscribing starts bc2's pump, but it must block on bc1's teardown before
  318. # producing any frame.
  319. queue = await bc2.subscribe()
  320. await asyncio.sleep(0.03)
  321. assert queue.empty(), "successor produced a frame before the prior upstream closed"
  322. # Predecessor teardown completes → bc2's pump dials and frames flow.
  323. bc1._teardown_complete.set() # noqa: SLF001
  324. assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"x"
  325. await shutdown_broadcaster("p1")
  326. async def test_successor_pump_times_out_if_predecessor_wedges(monkeypatch):
  327. """If a displaced broadcaster's teardown never completes, the successor's
  328. pump must dial anyway (bounded wait) rather than never producing a frame.
  329. """
  330. monkeypatch.setattr(camera_fanout, "_TEARDOWN_WAIT_SECONDS", 0.05)
  331. factory = _make_factory([b"x"] * 1000, delay=0.02)
  332. bc1 = MjpegBroadcaster("p1", factory)
  333. camera_fanout._broadcasters["p1"] = bc1
  334. bc1._stopped = True # noqa: SLF001 — wedged mid-teardown, event never set
  335. # teardown_complete intentionally never set.
  336. bc2 = await get_or_create_broadcaster("p1", factory)
  337. assert bc2 is not bc1
  338. queue = await bc2.subscribe()
  339. # After the bounded wait elapses the pump dials and delivers a frame.
  340. assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"x"
  341. await shutdown_broadcaster("p1")
  342. # ---------------------------------------------------------------------------
  343. # The printer only has ONE camera socket (#2521)
  344. # ---------------------------------------------------------------------------
  345. def _socket_counting_factory(state: dict, *, close_delay: float = 0.05):
  346. """Upstream factory that models a real TCP socket to the printer.
  347. Records the peak number of simultaneously-open sockets. A chamber-image cam
  348. (P1/A1, port 6000) accepts exactly one connection: when a second overlaps,
  349. the printer keeps feeding the first and the newcomer never sees a frame —
  350. until the printer's TCP keepalive reaps the orphan, ~20 minutes later.
  351. """
  352. async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
  353. await asyncio.sleep(0.01) # dial + TLS handshake
  354. state["open"] += 1
  355. state["peak"] = max(state["peak"], state["open"])
  356. try:
  357. while not disconnect.is_set():
  358. await asyncio.sleep(0.01)
  359. yield b"frame"
  360. finally:
  361. await asyncio.sleep(close_delay) # TCP close is not instantaneous
  362. state["open"] -= 1
  363. return factory
  364. async def test_stop_then_restream_never_opens_two_sockets():
  365. """A page reload fires POST /camera/stop and GET /camera/stream at the same
  366. time. ``shutdown_broadcaster`` used to *pop* the broadcaster out of the
  367. registry and only then await its teardown, so a stream request landing in
  368. that window found an empty slot, minted a broadcaster with no predecessor,
  369. and dialled the printer while the old socket was still closing (#2521).
  370. """
  371. state = {"open": 0, "peak": 0}
  372. factory = _socket_counting_factory(state)
  373. bc1 = await get_or_create_broadcaster("p1", factory)
  374. queue = await bc1.subscribe()
  375. assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"frame"
  376. await bc1.unsubscribe(queue)
  377. async def viewer_unmount_stop():
  378. await shutdown_broadcaster("p1")
  379. async def reloaded_page_streams():
  380. await asyncio.sleep(0.005) # lands a hair after the stop
  381. bc = await get_or_create_broadcaster("p1", factory)
  382. q = await bc.subscribe()
  383. return await asyncio.wait_for(q.get(), timeout=2.0)
  384. _stop_result, frame = await asyncio.gather(viewer_unmount_stop(), reloaded_page_streams())
  385. assert frame == b"frame", "the reloaded page's viewer never received a frame"
  386. assert state["peak"] == 1, (
  387. f"opened {state['peak']} concurrent sockets to a printer that allows one — "
  388. "the new stream dialled before the old socket closed"
  389. )
  390. await shutdown_broadcaster("p1")
  391. async def test_shutdown_broadcaster_leaves_a_chainable_predecessor():
  392. """The stopped broadcaster must stay findable in the registry: that is what
  393. lets the next viewer's pump chain behind its socket close."""
  394. state = {"open": 0, "peak": 0}
  395. factory = _socket_counting_factory(state)
  396. bc1 = await get_or_create_broadcaster("p1", factory)
  397. await bc1.subscribe()
  398. await shutdown_broadcaster("p1")
  399. assert camera_fanout._broadcasters.get("p1") is bc1, ( # noqa: SLF001
  400. "the stopped broadcaster was removed from the registry — a successor "
  401. "created now would have predecessor=None and dial immediately"
  402. )
  403. bc2 = await get_or_create_broadcaster("p1", factory)
  404. assert bc2._predecessor is bc1 # noqa: SLF001 — white-box: the chain is the fix
  405. await shutdown_broadcaster("p1")
  406. async def test_shutdown_broadcaster_is_idempotent():
  407. """/camera/stop can fire twice (unmount + beforeunload). The second call
  408. must report nothing was running rather than tearing down a live successor."""
  409. factory = _make_factory([b"x"] * 1000, delay=0.02)
  410. bc = await get_or_create_broadcaster("p1", factory)
  411. await bc.subscribe()
  412. assert await shutdown_broadcaster("p1") is True
  413. assert await shutdown_broadcaster("p1") is False
  414. assert await shutdown_broadcaster("never-existed") is False
  415. async def test_stopped_broadcaster_reports_no_subscribers():
  416. """/camera/stop's reference-count guard must not see the corpse's leftovers."""
  417. from backend.app.services.camera_fanout import get_subscriber_count
  418. factory = _make_factory([b"x"] * 1000, delay=0.02)
  419. bc = await get_or_create_broadcaster("p1", factory)
  420. await bc.subscribe()
  421. assert get_subscriber_count("p1") == 1
  422. await shutdown_broadcaster("p1")
  423. assert get_subscriber_count("p1") == 0, "a stopped broadcaster still reported subscribers"
  424. async def test_subscriber_with_no_frames_detaches_promptly():
  425. """A viewer that goes away while the stream is black must stop being counted.
  426. The disconnect check only ran after a chunk was yielded, or on a 30 s idle
  427. timeout — so a client that left during a black stream stayed *counted* as a
  428. subscriber for up to half a minute. /camera/stop trusts that count to decide
  429. whether to tear the upstream down, so a phantom subscriber could make it
  430. skip teardown entirely (#2521).
  431. """
  432. async def silent_factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
  433. await disconnect.wait() # connected, but the printer sends nothing
  434. return
  435. yield # pragma: no cover — makes this an async generator
  436. bc = MjpegBroadcaster("p1", silent_factory)
  437. queue = await bc.subscribe()
  438. assert bc.subscriber_count == 1
  439. async def is_disconnected() -> bool:
  440. return True # the browser aborted the request
  441. async def drain():
  442. async for _chunk in iter_subscriber(bc, queue, is_disconnected=is_disconnected):
  443. pass
  444. # Must notice well inside the old 30 s idle timeout.
  445. await asyncio.wait_for(drain(), timeout=3.0)
  446. assert bc.subscriber_count == 0
  447. await bc.force_shutdown()