mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 08:28:30 +00:00
111 lines
4.4 KiB
Python
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
|