Files
esphome/tests/integration/test_api_list_entities_backpressure.py
T

111 lines
4.4 KiB
Python

"""A client that stops reading the entity listing must not starve other clients.
Service responses are sent directly (not via the deferred batch), so a full
TCP pipe makes the send path refuse; the drive loop now lives in
try_advance(), which stops on refusal instead of retrying forever. Not a
before/after regression test: pre-fix builds survive here because the
refusal path yields and pumps the socket each retry.
The sndbuf_pin_component fixture pins the device's send buffers so the pipe
fills deterministically regardless of kernel autotuning; the test waits for
its log line before proceeding.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .types import APIClientConnectedFactory, RunCompiledFunction
SERVICES_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesServicesResponse]
LIST_DONE_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
# Both ends of the pipe are pinned small; only tens of KB fit in the kernel
RECV_BUFFER_SIZE = 4096
SERVER_SNDBUF = 8192 # substituted into the fixture yaml
# Logged by the sndbuf_pin_component fixture when it pins a socket
SNDBUF_PIN_LOG = "SO_SNDBUF pinned to"
# One response (~6.4 KB) must stay smaller than the pinned send buffer; an
# oversized message parks in the overflow buffer and reports as sent.
ARGS_PER_SERVICE = 8
ARG_NAME_LEN = 800
# ~160 KB listing versus a tens-of-KB pipe guarantees a mid-services block
NUM_SERVICES = 25
assert ARGS_PER_SERVICE * ARG_NAME_LEN < SERVER_SNDBUF
# The pipe fills in well under a second
STALL_SECONDS = 0.5
# Well above pipe capacity, well below the listing size
MIN_DRAINED_BYTES = 60_000
def _generated_actions() -> str:
"""Build the api actions block: services with long argument names."""
lines: list[str] = []
for i in range(NUM_SERVICES):
lines.append(f" - action: backpressure_service_{i:04d}")
lines.append(" variables:")
for j in range(ARGS_PER_SERVICE):
prefix = f"arg_{i:04d}_{j:02d}_"
lines.append(
f" {prefix}{'x' * (ARG_NAME_LEN - len(prefix))}: string"
)
lines.append(" then:")
lines.append(" - logger.log: service called")
return "\n".join(lines)
@pytest.mark.asyncio
async def test_api_list_entities_backpressure(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
"""A stalled reader mid-services must not block other api clients."""
assert "# GENERATED_ACTIONS" in yaml_config
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
pin_applied = asyncio.Event()
def _on_log_line(line: str) -> None:
if SNDBUF_PIN_LOG in line:
pin_applied.set()
async with run_compiled(config, line_callback=_on_log_line):
# Fails loudly if the pin never applied
await asyncio.wait_for(pin_applied.wait(), 10)
async with RawApiClient(
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
) as stalled:
await stalled.connect(client_info="backpressure-stall-client")
await stalled.send_message(api_pb2.ListEntitiesRequest())
# The client now stops reading entirely.
# Let the server run against the full pipe
await asyncio.sleep(STALL_SECONDS)
# Other clients must still be served while the first is blocked
async with api_client_connected(timeout=20) as client:
device_info = await asyncio.wait_for(client.device_info(), 20)
assert device_info.name == "api-backpressure-test"
_, services = await asyncio.wait_for(
client.list_entities_services(), 30
)
assert len(services) == NUM_SERVICES
# Fixture-size guard: the listing must dwarf the pinned pipe
before = stalled.bytes_received
await stalled.read_until_frame(LIST_DONE_RESPONSE, timeout=60)
drained = stalled.bytes_received - before
assert drained > MIN_DRAINED_BYTES, (
f"only {drained} bytes drained; the listing never backed up"
)
assert stalled.frame_counts[SERVICES_RESPONSE] == NUM_SERVICES
assert stalled.frame_counts[LIST_DONE_RESPONSE] == 1