Files
privateGPT/tests/components/streaming/test_stream_multiplexer.py
Javier Martinez cd8ca2214a feat: resumable chat worker + tool worker + async tokenizer (#2298)
* fix: avoid to block the loop

* fix: blocks in expansion

* fix: remove maximum concurrent users

...

* fix: multiplexer

* fix: readers

* fix: more fixes

...

* fix: impl

* feat: tool scheduler

* feat: add adaptative

* feat: add chat worker

* fix: max

* feat: add chat/tools workers

* fix: mypy

* feat: add generic scheduler

* fix: get result

* feat: do serializable the tool executor

* fix: tools

* fix: config

* fix: config

* fix: args

* fix: config

* fix: serializer

* Revert "fix: blocks in expansion"

This reverts commit a2110f94a8.

* fix: unify all logic

* feat: add ingestion scheduler

* fix: settings

* fix: config

* feat: add arq worker to chat

* fix: arq worker

* fix: add nest

* fix: mypy

* fix: await

* fix: script stress

* fix: tokenizer

* fix: chat scheduler

* fix: mypy

* fix: add async tokenizer

* fix: improve condense

* fix: tool scheduler

* feat: add initial real async chat worker

* fix: mypy

* fix: do resumable local executor

...

...

...

fix: revert usleess changes

fix: remove parent chat job

fix: refactor

fix: loop

ref: rename models

fix: chat engine

fix: mypy

...

...

...

fix: fix deps

* fix: tests

* fix: tests

* ...

* fix: stream

* fix: config

* fix: scheduler

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Handle PGPT_WORKER_MODE=celery in health check worker status

* fix: cancel

* fix: arch

* fix: test ingestion

* fix: deserialization of chat messages

* fix: broken results

* fix: mypy

* fix: test

* fix: config

* fix: remove arq tool worker

* fix: output cls

* fix: preserve early resumable tool callbacks

* fix: preserve async tool result order

* refactor: address worker PR review comments

* fix: mypy

* test: colocate ARQ chat enqueue coverage

* fix: remove redis from tests

* test: isolate chat mocks and cancellation timing

* fix: tests

(cherry picked from commit 218b599c66)

# Conflicts:
#	tests/server/chat/anthropic/test_anthropic_client.py
#	tests/server/chat/anthropic/test_langchain_anthropic.py
#	tests/server/chat/test_chat_knowledge_revamp.py
#	tests/server/chat/test_chat_routes.py
#	tests/server/chat/test_chat_routes_skills_integration.py

* fix: tests

(cherry picked from commit fc5ec0f72a)

* fix: ruff

* fix: test

* fix: worker config

(cherry picked from commit 1371c275a1)

* fix: principal

* test: remove flaky chat cancellation assertion

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 12:12:45 +02:00

704 lines
26 KiB
Python

"""Tests for StreamMultiplexer and the streaming readers.
The core strategy is **differential testing**: each scenario is run through the
direct path (StreamReader.stream_events) AND the multiplexed path
(StreamMultiplexer), and the two results must be identical. If they ever
diverge, the multiplexer is broken.
Section layout
--------------
1. Differential tests — direct == multiplexed for every scenario.
2. Multiplexer concurrency — late joiners, concurrent remove, multi-consumer.
3. Terminal detection — events in the terminal gap are drained.
4. Lifecycle — stop/restart leave no stale state.
5. Performance — worker does not busy-poll.
6. Backend block semantics — block_ms=None is non-blocking.
"""
import asyncio
import json
import random
from typing import Any
import pytest
from pydantic import BaseModel
from private_gpt.components.streaming.providers.in_memory_stream_service import (
InMemoryStreamService,
)
from private_gpt.components.streaming.providers.models import StreamStatus
from private_gpt.components.streaming.providers.stream_service import StreamService
from private_gpt.components.streaming.stream.stream_reader import (
DEFAULT_BLOCK_MS,
StreamConsumer,
StreamMultiplexer,
StreamReader,
)
STRESS_N = 500
# ──────────────────────────── helpers / models ────────────────────────────────
class SimpleEvent(BaseModel):
n: int
class SimpleEventHandler:
def serialize(self, event: BaseModel) -> str:
return event.model_dump_json()
def deserialize(self, data: str) -> BaseModel:
return SimpleEvent.model_validate_json(data)
async def get_current_status(self, event: BaseModel) -> StreamStatus | None:
return None
def error_event(self, correlation_id: str, error: Exception) -> BaseModel:
return SimpleEvent(n=-1)
class SimpleEventHandler2(SimpleEventHandler):
"""Distinct type so add_consumer creates a separate consumer."""
class MockStreamComponent:
def __init__(self, service: StreamService) -> None:
self.stream = service
async def collect_from_consumer(
consumer: StreamConsumer,
timeout: float = 10.0,
) -> list[BaseModel]:
"""Collect all events from a consumer's broadcast until it is closed."""
events: list[BaseModel] = []
async def drain() -> None:
async for event in consumer.broadcast.read_from(cursor=consumer.cursor):
events.append(event)
await asyncio.wait_for(drain(), timeout=timeout)
return events
async def run_direct(
stream_reader: StreamReader,
handler: SimpleEventHandler,
cid: str,
last_id: str = "0",
) -> list[BaseModel]:
received: list[BaseModel] = []
gen = await stream_reader.stream_events(
event_handler=handler,
correlation_id=cid,
last_id=last_id,
)
async for event in gen:
received.append(event)
return received
async def run_multiplexed(
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
cid: str,
last_id: str = "0",
) -> list[BaseModel]:
consumer = await multiplexer.add_consumer(cid, handler, last_id=last_id)
return await collect_from_consumer(consumer, timeout=30.0)
async def push_n_and_complete(
service: StreamService,
cid: str,
n: int,
*,
burst: int = 10,
max_jitter: float = 0.0,
) -> None:
for i in range(n):
await service.push_event(cid, json.dumps({"n": i}))
if (i + 1) % burst == 0:
await asyncio.sleep(random.uniform(0, max_jitter))
await asyncio.sleep(random.uniform(0, max_jitter * 2))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
def assert_ordered_0_to_n(events: list[BaseModel], n: int) -> None:
ns = [e.n for e in events] # type: ignore[attr-defined]
missing = sorted(set(range(n)) - set(ns))
duplicates = sorted({x for x in ns if ns.count(x) > 1})
assert not missing, f"missing {len(missing)} events: {missing[:10]}"
assert not duplicates, f"duplicates: {duplicates[:10]}"
assert ns == sorted(ns), "events arrived out of order"
assert len(ns) == n
# ──────────────────────────── fixtures ───────────────────────────────────────
@pytest.fixture
def service() -> StreamService:
return InMemoryStreamService()
@pytest.fixture
def stream_reader(service: StreamService) -> StreamReader:
return StreamReader(MockStreamComponent(service)) # type: ignore[arg-type]
@pytest.fixture
def multiplexer(stream_reader: StreamReader) -> StreamMultiplexer:
return StreamMultiplexer(stream_reader)
@pytest.fixture
def handler() -> SimpleEventHandler:
return SimpleEventHandler()
@pytest.fixture
def handler2() -> SimpleEventHandler2:
return SimpleEventHandler2()
# ═══════════════════════════════════════════════════════════════════════════════
# 1. Differential tests — direct path must equal multiplexed path
# ═══════════════════════════════════════════════════════════════════════════════
async def test_both_paths_prepopulated(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
await service.push_event(cid, json.dumps({"n": 2}))
await service.push_event(cid, json.dumps({"n": 3}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
try:
mux = await run_multiplexed(multiplexer, handler, cid)
finally:
await multiplexer.stop()
direct = await run_direct(stream_reader, handler, cid)
assert [e.n for e in mux] == [e.n for e in direct] == [1, 2, 3] # type: ignore[attr-defined]
async def test_both_paths_empty_stream(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
cid = await service.create_stream("test")
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
try:
mux = await run_multiplexed(multiplexer, handler, cid)
finally:
await multiplexer.stop()
direct = await run_direct(stream_reader, handler, cid)
assert mux == direct == []
async def test_both_paths_reconnect_from_mid(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
"""A consumer that already has event n=1 (it knows last_id=e1_id) must
receive only events after that point — never a replay from "0"."""
cid = await service.create_stream("test")
e1_id = await service.push_event(cid, json.dumps({"n": 1}))
await service.push_event(cid, json.dumps({"n": 2}))
await service.push_event(cid, json.dumps({"n": 3}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
try:
mux = await run_multiplexed(multiplexer, handler, cid, last_id=e1_id)
finally:
await multiplexer.stop()
direct = await run_direct(stream_reader, handler, cid, last_id=e1_id)
assert [e.n for e in mux] == [e.n for e in direct] == [2, 3] # type: ignore[attr-defined]
async def test_both_paths_reconnect_from_tail(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
"""Consumer is caught up to the last event; both paths return nothing."""
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
await service.push_event(cid, json.dumps({"n": 2}))
e3_id = await service.push_event(cid, json.dumps({"n": 3}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
try:
mux = await run_multiplexed(multiplexer, handler, cid, last_id=e3_id)
finally:
await multiplexer.stop()
direct = await run_direct(stream_reader, handler, cid, last_id=e3_id)
assert mux == direct == []
async def test_both_paths_live_producer(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
"""Events arrive while both consumers are already blocking on the stream.
Both paths must receive every event in order."""
cid = await service.create_stream("test")
await multiplexer.start()
try:
mux_task = asyncio.create_task(run_multiplexed(multiplexer, handler, cid))
direct_task = asyncio.create_task(run_direct(stream_reader, handler, cid))
await asyncio.sleep(0.15)
await push_n_and_complete(service, cid, 100, burst=10, max_jitter=0.003)
mux = await asyncio.wait_for(mux_task, timeout=30.0)
direct = await asyncio.wait_for(direct_task, timeout=30.0)
finally:
await multiplexer.stop()
mux_ns = [e.n for e in mux] # type: ignore[attr-defined]
direct_ns = [e.n for e in direct] # type: ignore[attr-defined]
assert mux_ns == direct_ns
assert_ordered_0_to_n(mux, 100)
assert_ordered_0_to_n(direct, 100)
@pytest.mark.parametrize(
("burst", "max_jitter"),
[
(1, 0.0),
(10, 0.003),
(STRESS_N, 0.0),
],
ids=["one-at-a-time", "small-bursts", "single-burst"],
)
async def test_both_paths_stress(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
burst: int,
max_jitter: float,
) -> None:
cid = await service.create_stream("test")
await push_n_and_complete(
service, cid, STRESS_N, burst=burst, max_jitter=max_jitter
)
await multiplexer.start()
try:
mux = await run_multiplexed(multiplexer, handler, cid)
finally:
await multiplexer.stop()
direct = await run_direct(stream_reader, handler, cid)
assert_ordered_0_to_n(mux, STRESS_N)
assert_ordered_0_to_n(direct, STRESS_N)
assert [e.n for e in mux] == [e.n for e in direct] # type: ignore[attr-defined]
# ═══════════════════════════════════════════════════════════════════════════════
# 2. Multiplexer concurrency
# ═══════════════════════════════════════════════════════════════════════════════
async def test_late_joiner_receives_inflight_batch(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
handler2: SimpleEventHandler2,
) -> None:
"""A consumer that joins while the worker is blocked inside read_events()
must still receive the batch that the worker is about to dispatch."""
cid = await service.create_stream("test")
original_read = stream_reader.read_events
late_box: dict[str, Any] = {}
async def read_then_join(
event_handler: Any,
correlation_id: str,
last_id: str = "0",
count: int | None = None,
block_ms: int | None = None,
) -> tuple[list[BaseModel], str]:
result = await original_read(
event_handler=event_handler,
correlation_id=correlation_id,
last_id=last_id,
count=count,
block_ms=block_ms,
)
if result[0] and "c2" not in late_box:
late_box["c2"] = await multiplexer.add_consumer(cid, handler2)
return result
stream_reader.read_events = read_then_join # type: ignore[method-assign]
await multiplexer.start()
c1 = await multiplexer.add_consumer(cid, handler)
await service.push_event(cid, json.dumps({"n": 1}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
try:
recv1 = await collect_from_consumer(c1, timeout=10.0)
c2 = late_box["c2"]
recv2 = await collect_from_consumer(c2, timeout=10.0)
finally:
await multiplexer.stop()
stream_reader.read_events = original_read # type: ignore[method-assign]
assert 1 in [e.n for e in recv1] # type: ignore[attr-defined]
assert 1 in [e.n for e in recv2], "late joiner missed the in-flight batch" # type: ignore[attr-defined]
async def test_concurrent_remove_preserves_delivery(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
handler2: SimpleEventHandler2,
) -> None:
"""If one consumer is removed while the worker is reading, the remaining
consumers still receive the batch."""
cid = await service.create_stream("test")
original_read = stream_reader.read_events
removed = False
async def read_then_remove(
event_handler: Any,
correlation_id: str,
last_id: str = "0",
count: int | None = None,
block_ms: int | None = None,
) -> tuple[list[BaseModel], str]:
result = await original_read(
event_handler=event_handler,
correlation_id=correlation_id,
last_id=last_id,
count=count,
block_ms=block_ms,
)
nonlocal removed
if result[0] and not removed:
await multiplexer.remove_consumer(c2)
removed = True
return result
stream_reader.read_events = read_then_remove # type: ignore[method-assign]
await multiplexer.start()
c1 = await multiplexer.add_consumer(cid, handler)
c2 = await multiplexer.add_consumer(cid, handler2)
await service.push_event(cid, json.dumps({"n": 1}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
try:
recv1 = await collect_from_consumer(c1, timeout=5.0)
finally:
await multiplexer.stop()
stream_reader.read_events = original_read # type: ignore[method-assign]
assert [e.n for e in recv1] == [1] # type: ignore[attr-defined]
async def test_multiple_consumers_all_receive(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
handler2: SimpleEventHandler2,
) -> None:
"""Two distinct consumers on the same stream both receive every event."""
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
await service.push_event(cid, json.dumps({"n": 2}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
c1 = await multiplexer.add_consumer(cid, handler)
c2 = await multiplexer.add_consumer(cid, handler2)
try:
recv1 = await collect_from_consumer(c1, timeout=10.0)
recv2 = await collect_from_consumer(c2, timeout=10.0)
finally:
await multiplexer.stop()
assert [e.n for e in recv1] == [1, 2] # type: ignore[attr-defined]
assert [e.n for e in recv2] == [1, 2] # type: ignore[attr-defined]
async def test_late_joiner_gets_full_history(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
handler2: SimpleEventHandler2,
) -> None:
"""A consumer that joins AFTER the first batch has already been dispatched
must still receive every event from the beginning (last_id='0').
This is the 'Delta without start' bug: if a content_block_start event is
in batch 0 and a second consumer only sees batch 1 onwards, the client
receives content_block_delta with no preceding start.
"""
cid = await service.create_stream("test")
# Patch read_events so that after the first successful dispatch we can
# confirm the broadcast already has batch 0, then add a second consumer.
original_read = stream_reader.read_events
second_consumer_box: dict[str, Any] = {}
dispatch_count = 0
async def read_after_first_dispatch(
event_handler: Any,
correlation_id: str,
last_id: str = "0",
count: int | None = None,
block_ms: int | None = None,
) -> tuple[list[BaseModel], str]:
nonlocal dispatch_count
result = await original_read(
event_handler=event_handler,
correlation_id=correlation_id,
last_id=last_id,
count=count,
block_ms=block_ms,
)
# After the first batch is returned (and will be dispatched), add c2.
# At this point broadcast._batches is still empty (dispatch hasn't run),
# but after dispatch it will have 1 batch — c2 must still see it.
if result[0] and dispatch_count == 0 and "c2" not in second_consumer_box:
dispatch_count += 1
# Wait one event-loop tick so _dispatch runs and pushes batch 0.
await asyncio.sleep(0)
second_consumer_box["c2"] = await multiplexer.add_consumer(
cid, handler2, last_id="0"
)
return result
stream_reader.read_events = read_after_first_dispatch # type: ignore[method-assign]
await multiplexer.start()
c1 = await multiplexer.add_consumer(cid, handler, last_id="0")
await service.push_event(cid, json.dumps({"n": 1}))
await service.push_event(cid, json.dumps({"n": 2}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
try:
recv1 = await collect_from_consumer(c1)
c2 = second_consumer_box["c2"]
recv2 = await collect_from_consumer(c2)
finally:
await multiplexer.stop()
stream_reader.read_events = original_read # type: ignore[method-assign]
assert [e.n for e in recv1] == [1, 2], "consumer 1 must receive all events" # type: ignore[attr-defined]
assert [e.n for e in recv2] == [1, 2], "late joiner must not miss batch 0" # type: ignore[attr-defined]
# ═══════════════════════════════════════════════════════════════════════════════
# 3. Terminal detection — events in the gap are drained
# ═══════════════════════════════════════════════════════════════════════════════
async def test_events_in_terminal_gap_drained(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
"""Events published between the worker's empty read and the terminal-status
check must be drained and delivered, not dropped."""
cid = await service.create_stream("test")
original_check = stream_reader.check_terminal_status
async def injecting_check(
correlation_id: str,
event_handler: Any,
cached_events: Any,
) -> bool:
await service.push_event(correlation_id, json.dumps({"n": 1}))
await service.push_event(correlation_id, json.dumps({"n": 2}))
await service.update_stream_status(correlation_id, StreamStatus.COMPLETED)
return True
stream_reader.check_terminal_status = injecting_check # type: ignore[method-assign]
await multiplexer.start()
consumer = await multiplexer.add_consumer(cid, handler)
try:
received = await collect_from_consumer(consumer, timeout=10.0)
finally:
await multiplexer.stop()
stream_reader.check_terminal_status = original_check # type: ignore[method-assign]
assert [e.n for e in received] == [1, 2] # type: ignore[attr-defined]
# ═══════════════════════════════════════════════════════════════════════════════
# 4. Lifecycle — stop / restart leave no stale state
# ═══════════════════════════════════════════════════════════════════════════════
async def test_stop_clears_state_and_closes_consumers(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
await multiplexer.start()
consumer = await multiplexer.add_consumer(cid, handler)
# Wait until the worker has pushed at least one batch to the broadcast.
async def get_one() -> None:
async for _ in consumer.broadcast.read_from(cursor=consumer.cursor):
break
await asyncio.wait_for(get_one(), timeout=5.0)
await multiplexer.stop()
assert not multiplexer.consumers, "consumers dict must be empty after stop()"
assert not multiplexer.states, "states dict must be empty after stop()"
assert consumer.broadcast.is_closed, "broadcast must be closed after stop()"
async def test_restart_after_stop(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
await service.update_stream_status(cid, StreamStatus.COMPLETED)
await multiplexer.start()
await multiplexer.add_consumer(cid, handler)
await multiplexer.stop()
# Second stream on a fresh cid after restart.
cid2 = await service.create_stream("test")
await service.push_event(cid2, json.dumps({"n": 42}))
await service.update_stream_status(cid2, StreamStatus.COMPLETED)
await multiplexer.start()
try:
consumer = await multiplexer.add_consumer(cid2, handler)
received = await collect_from_consumer(consumer, timeout=10.0)
finally:
await multiplexer.stop()
assert [e.n for e in received] == [42] # type: ignore[attr-defined]
async def test_stop_idempotent(
multiplexer: StreamMultiplexer,
) -> None:
await multiplexer.start()
await multiplexer.stop()
await multiplexer.stop() # must not raise
# ═══════════════════════════════════════════════════════════════════════════════
# 5. Performance — worker does not busy-poll
# ═══════════════════════════════════════════════════════════════════════════════
async def test_no_busy_poll(
service: StreamService,
stream_reader: StreamReader,
multiplexer: StreamMultiplexer,
handler: SimpleEventHandler,
) -> None:
"""With block_ms=DEFAULT_BLOCK_MS the worker parks on the backend; it must
not spin. In 300 ms we expect at most one read call."""
cid = await service.create_stream("test")
read_count = 0
original_read = stream_reader.read_events
async def counting_read(
event_handler: Any,
correlation_id: str,
last_id: str = "0",
count: int | None = None,
block_ms: int | None = None,
) -> tuple[list[BaseModel], str]:
nonlocal read_count
read_count += 1
return await original_read(
event_handler=event_handler,
correlation_id=correlation_id,
last_id=last_id,
count=count,
block_ms=block_ms,
)
stream_reader.read_events = counting_read # type: ignore[method-assign]
await multiplexer.start()
await multiplexer.add_consumer(cid, handler)
await asyncio.sleep(0.3)
await multiplexer.stop()
stream_reader.read_events = original_read # type: ignore[method-assign]
assert read_count <= 1, (
f"Worker called read_events {read_count} times in 300 ms "
f"(expected ≤1 with block_ms={DEFAULT_BLOCK_MS})."
)
# ═══════════════════════════════════════════════════════════════════════════════
# 6. Backend block semantics — block_ms=None is non-blocking
# ═══════════════════════════════════════════════════════════════════════════════
async def test_block_ms_none_is_non_blocking(
service: StreamService,
stream_reader: StreamReader,
handler: SimpleEventHandler,
) -> None:
"""block_ms=None means non-blocking on both backends. This is what the
direct path's final drain relies on, and it must never hang."""
cid = await service.create_stream("test")
await service.push_event(cid, json.dumps({"n": 1}))
e1_id = await service.push_event(cid, json.dumps({"n": 2}))
# Read past e1; no events remain.
events, _ = await asyncio.wait_for(
stream_reader.read_events(handler, cid, e1_id, None, None),
timeout=2.0,
)
assert events == []