From 6002c28a8d0c92f22bd548c357e1c3510ad0ecaa Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:38:16 +0200 Subject: [PATCH] [host] Ignore SIGPIPE so a write to a closed peer fails with EPIPE (#20228) --- esphome/components/host/core.cpp | 2 + .../fixtures/host_write_after_peer_close.yaml | 40 +++++++++++++ .../test_host_write_after_peer_close.py | 57 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 tests/integration/fixtures/host_write_after_peer_close.yaml create mode 100644 tests/integration/test_host_write_after_peer_close.py diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 9292cd77f6..f753534efd 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -90,6 +90,8 @@ int main(int argc, char **argv) { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); + // A write to a closed peer must fail with EPIPE, as on the embedded stacks, not kill the process + std::signal(SIGPIPE, SIG_IGN); esphome::host::setup_preferences(); setup(); diff --git a/tests/integration/fixtures/host_write_after_peer_close.yaml b/tests/integration/fixtures/host_write_after_peer_close.yaml new file mode 100644 index 0000000000..4fa89907e3 --- /dev/null +++ b/tests/integration/fixtures/host_write_after_peer_close.yaml @@ -0,0 +1,40 @@ +esphome: + name: host-write-after-close + +host: + +api: + actions: + - action: write_after_close + variables: + port: int + then: + - lambda: |- + // A second connection to the test server carries the go signal. + auto sync = socket::socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_storage addr; + socklen_t len = socket::set_sockaddr(reinterpret_cast(&addr), sizeof(addr), "127.0.0.1", port); + struct timeval timeout = {10, 0}; + sync->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + uint8_t go; + // The loop stays blocked, so the link cannot read the close before the writes. + if (sync->connect(reinterpret_cast(&addr), len) != 0 || sync->read(&go, 1) != 1) { + ESP_LOGE("test", "No go signal"); + return; + } + // The first write draws a reset from the closed peer, a later one fails with EPIPE. + uint32_t start = millis(); + while (id(tcp_uart_1).is_connected() && millis() - start < 5000) { + id(tcp_uart_1).write_byte('b'); + id(tcp_uart_1).flush(); + delay(1); + } + +logger: + level: INFO + +tcp_uart: + - id: tcp_uart_1 + host: 127.0.0.1 + port: 18126 + reconnect_interval: 60s diff --git a/tests/integration/test_host_write_after_peer_close.py b/tests/integration/test_host_write_after_peer_close.py new file mode 100644 index 0000000000..3827459b48 --- /dev/null +++ b/tests/integration/test_host_write_after_peer_close.py @@ -0,0 +1,57 @@ +"""A host build must survive a write to a peer that has closed the connection. + +The device writes until the link drops; once the closed peer has answered with +a reset, a write must fail with EPIPE instead of SIGPIPE killing the process. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_write_after_peer_close( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + unused_tcp_port_factory: Callable[[], int], +) -> None: + port = unused_tcp_port_factory() + yaml_config = yaml_config.replace("port: 18126", f"port: {port}") + + lines = LineWaiter() + peers: asyncio.Queue[asyncio.StreamWriter] = asyncio.Queue() + + async def handle(_: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + peers.put_nowait(writer) + + server = await asyncio.start_server(handle, "127.0.0.1", port) + try: + async with ( + run_compiled(yaml_config, line_callback=lines.callback), + api_client_connected() as client, + ): + peer = await asyncio.wait_for(peers.get(), 15.0) + _, services = await client.list_entities_services() + action = next(s for s in services if s.name == "write_after_close") + + await client.execute_service(action, {"port": port}) + # The action opens a second connection and blocks the loop until it gets a byte. + sync = await asyncio.wait_for(peers.get(), 15.0) + peer.close() + await peer.wait_closed() + sync.write(b"g") + sync.close() + + # EPIPE (32 on Linux and macOS) must come back as an errno. + await lines.wait_for("Connection lost: 32") + assert await client.device_info() is not None + finally: + server.close() + await server.wait_closed()