mirror of
https://github.com/esphome/esphome.git
synced 2026-09-06 04:56:04 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff50ffa154 | ||
|
|
c31e02fa0c | ||
|
|
562e5079d1 | ||
|
|
a875016b1a |
@@ -553,7 +553,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cerrno>
|
||||
#include <sys/select.h>
|
||||
|
||||
namespace esphome::async_tcp {
|
||||
|
||||
@@ -41,15 +42,7 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (socket_->setblocking(false) != 0) {
|
||||
// Capture before the log and close() clobber errno
|
||||
const int saved_errno = errno;
|
||||
ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno);
|
||||
close();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, saved_errno);
|
||||
return false;
|
||||
}
|
||||
socket_->setblocking(false);
|
||||
|
||||
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
|
||||
if (err == 0) {
|
||||
@@ -104,22 +97,45 @@ void AsyncClient::loop() {
|
||||
return;
|
||||
|
||||
if (connecting_) {
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*socket_, err)) {
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
|
||||
// For connecting, we need to check writability, not readability
|
||||
// The Application's select() only monitors read FDs, so we do our own check here
|
||||
// For ESP platforms lwip_select() might be faster, but this code isn't used
|
||||
// on those platforms anyway. If it was, we'd fix the Application select()
|
||||
// to report writability instead of doing it this way.
|
||||
int fd = socket_->get_fd();
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "Invalid socket fd");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
fd_set writefds;
|
||||
FD_ZERO(&writefds);
|
||||
FD_SET(fd, &writefds);
|
||||
|
||||
struct timeval tv = {0, 0};
|
||||
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
|
||||
if (ret > 0 && FD_ISSET(fd, &writefds)) {
|
||||
int error = 0;
|
||||
socklen_t len = sizeof(error);
|
||||
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
|
||||
connecting_ = false;
|
||||
connected_ = true;
|
||||
if (connect_cb_)
|
||||
connect_cb_(connect_arg_, this);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
|
||||
ESP_LOGW(TAG, "Connection failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Connection failed: %d", error);
|
||||
close();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, err);
|
||||
break;
|
||||
error_cb_(error_arg_, this, error);
|
||||
}
|
||||
} else if (ret < 0) {
|
||||
const int err = errno;
|
||||
ESP_LOGE(TAG, "Select error: %d", err);
|
||||
close();
|
||||
if (error_cb_)
|
||||
error_cb_(error_arg_, this, err);
|
||||
}
|
||||
} else if (connected_) {
|
||||
// For connected sockets, use the Application's select() results
|
||||
|
||||
@@ -407,10 +407,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
tv.tv_usec = 0;
|
||||
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
if (this->client_->setblocking(true) != 0) {
|
||||
this->log_socket_error_(LOG_STR("blocking"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
this->client_->setblocking(true);
|
||||
|
||||
// Acknowledge auth OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
|
||||
@@ -59,15 +59,13 @@ int BSDSocketImpl::close() {
|
||||
|
||||
int BSDSocketImpl::setblocking(bool blocking) {
|
||||
int fl = ::fcntl(this->fd_, F_GETFL, 0);
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
return ::fcntl(this->fd_, F_SETFL, fl);
|
||||
::fcntl(this->fd_, F_SETFL, fl);
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||
|
||||
@@ -205,13 +205,6 @@ static constexpr size_t SOCKADDR_STR_LEN = 46; // INET6_ADDRSTRLEN
|
||||
static constexpr size_t SOCKADDR_STR_LEN = 16; // INET_ADDRSTRLEN
|
||||
#endif
|
||||
|
||||
/// Outcome of polling a non-blocking connect(); see socket::poll_connect().
|
||||
enum class ConnectPollResult : uint8_t {
|
||||
CONNECT_POLL_RESULT_PENDING,
|
||||
CONNECT_POLL_RESULT_CONNECTED,
|
||||
CONNECT_POLL_RESULT_ERROR,
|
||||
};
|
||||
|
||||
} // namespace esphome::socket
|
||||
|
||||
#endif
|
||||
|
||||
@@ -48,33 +48,8 @@ static const char *const TAG = "socket";
|
||||
#ifdef USE_ESP8266
|
||||
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
|
||||
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
|
||||
// Let SYS run so queued WiFi traffic reaches lwip; CONT and SYS are cooperative
|
||||
static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); }
|
||||
#else
|
||||
static inline void yield_to_sys() {}
|
||||
#endif
|
||||
|
||||
// errno for a failed tcp_* call
|
||||
static int lwip_err_to_errno(err_t err) {
|
||||
switch (err) {
|
||||
case ERR_MEM:
|
||||
return ENOMEM;
|
||||
case ERR_BUF:
|
||||
return EAGAIN; // transient, e.g. no free local port
|
||||
case ERR_RTE:
|
||||
return EHOSTUNREACH; // no route, e.g. no address yet
|
||||
case ERR_VAL:
|
||||
case ERR_ARG:
|
||||
return EINVAL;
|
||||
case ERR_USE:
|
||||
return EADDRINUSE;
|
||||
case ERR_ISCONN:
|
||||
return EISCONN;
|
||||
default:
|
||||
return EIO;
|
||||
}
|
||||
}
|
||||
|
||||
// set to 1 to enable verbose lwip logging
|
||||
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
|
||||
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
|
||||
@@ -87,8 +62,8 @@ static int lwip_err_to_errno(err_t err) {
|
||||
// Must be called before destroying the object that tcp_arg points to —
|
||||
// tcp_abort() triggers the err callback synchronously, which would
|
||||
// otherwise call back into a partially-destroyed object.
|
||||
// tcp_sent/tcp_poll are never registered and the connect callback cannot
|
||||
// fire after abort or close, so neither is cleared.
|
||||
// tcp_sent/tcp_poll are not cleared because this implementation
|
||||
// never registers them.
|
||||
static void pcb_detach_abort(struct tcp_pcb *pcb) {
|
||||
tcp_arg(pcb, nullptr);
|
||||
tcp_recv(pcb, nullptr);
|
||||
@@ -101,7 +76,8 @@ static void pcb_detach_abort(struct tcp_pcb *pcb) {
|
||||
// After tcp_close(), the PCB remains alive during the TCP close handshake
|
||||
// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP
|
||||
// would call recv/err on a destroyed socket object, corrupting the heap.
|
||||
// Callbacks are left as in pcb_detach_abort().
|
||||
// tcp_sent/tcp_poll are not cleared because this implementation
|
||||
// never registers them.
|
||||
// Returns ERR_OK on success; on failure the PCB is aborted instead.
|
||||
static err_t pcb_detach_close(struct tcp_pcb *pcb) {
|
||||
tcp_arg(pcb, nullptr);
|
||||
@@ -125,51 +101,67 @@ LWIPRawCommon::~LWIPRawCommon() {
|
||||
}
|
||||
}
|
||||
|
||||
bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const {
|
||||
if (name == nullptr) {
|
||||
errno = EINVAL;
|
||||
return false;
|
||||
}
|
||||
#if LWIP_IPV6
|
||||
if (this->family_ == AF_INET6) {
|
||||
if (addrlen < sizeof(sockaddr_in6)) {
|
||||
errno = EINVAL;
|
||||
return false;
|
||||
}
|
||||
auto *addr6 = reinterpret_cast<const sockaddr_in6 *>(name);
|
||||
*port = ntohs(addr6->sin6_port);
|
||||
inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr);
|
||||
// ANY lets bind() accept both families; connect() picks the concrete type
|
||||
IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) {
|
||||
errno = EINVAL;
|
||||
return false;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
|
||||
*port = ntohs(addr4->sin_port);
|
||||
ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr);
|
||||
return true;
|
||||
}
|
||||
|
||||
int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) {
|
||||
LWIP_LOCK();
|
||||
if (this->pcb_ == nullptr) {
|
||||
errno = EBADF;
|
||||
return -1;
|
||||
}
|
||||
ip_addr_t ip;
|
||||
uint16_t port;
|
||||
if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) {
|
||||
if (name == nullptr) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
|
||||
ip_addr_t ip;
|
||||
in_port_t port;
|
||||
#if LWIP_IPV6
|
||||
if (this->family_ == AF_INET) {
|
||||
if (addrlen < sizeof(sockaddr_in)) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
|
||||
port = ntohs(addr4->sin_port);
|
||||
ip.type = IPADDR_TYPE_V4;
|
||||
ip.u_addr.ip4.addr = addr4->sin_addr.s_addr;
|
||||
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip4addr_ntoa(&ip.u_addr.ip4), port);
|
||||
} else if (this->family_ == AF_INET6) {
|
||||
if (addrlen < sizeof(sockaddr_in6)) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
auto *addr6 = reinterpret_cast<const sockaddr_in6 *>(name);
|
||||
port = ntohs(addr6->sin6_port);
|
||||
ip.type = IPADDR_TYPE_ANY;
|
||||
memcpy(&ip.u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16);
|
||||
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip6addr_ntoa(&ip.u_addr.ip6), port);
|
||||
} else {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
#else
|
||||
if (this->family_ != AF_INET) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
|
||||
port = ntohs(addr4->sin_port);
|
||||
ip.addr = addr4->sin_addr.s_addr;
|
||||
LWIP_LOG("tcp_bind(%p ip=%u port=%u)", this->pcb_, ip.addr, port);
|
||||
#endif
|
||||
err_t err = tcp_bind(this->pcb_, &ip, port);
|
||||
if (err == ERR_USE) {
|
||||
LWIP_LOG(" -> err ERR_USE");
|
||||
errno = EADDRINUSE;
|
||||
return -1;
|
||||
}
|
||||
if (err == ERR_VAL) {
|
||||
LWIP_LOG(" -> err ERR_VAL");
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
if (err != ERR_OK) {
|
||||
LWIP_LOG(" -> err %d", err);
|
||||
errno = lwip_err_to_errno(err);
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
@@ -186,7 +178,7 @@ int LWIPRawCommon::close() {
|
||||
this->pcb_ = nullptr;
|
||||
if (err != ERR_OK) {
|
||||
LWIP_LOG(" -> err %d", err);
|
||||
errno = lwip_err_to_errno(err);
|
||||
errno = err == ERR_MEM ? ENOMEM : EIO;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
@@ -213,7 +205,7 @@ int LWIPRawCommon::shutdown(int how) {
|
||||
err_t err = tcp_shutdown(this->pcb_, shut_rx, shut_tx);
|
||||
if (err != ERR_OK) {
|
||||
LWIP_LOG(" -> err %d", err);
|
||||
errno = lwip_err_to_errno(err);
|
||||
errno = err == ERR_MEM ? ENOMEM : EIO;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
@@ -433,82 +425,7 @@ void LWIPRawImpl::s_err_fn(void *arg, err_t err) {
|
||||
// ERR_ABRT: aborted through tcp_abort or TCP timer
|
||||
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
|
||||
ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err);
|
||||
if (arg_this->connect_err_ == EINPROGRESS) {
|
||||
// Refused (RST) or SYN retries exhausted; written before pcb_ so
|
||||
// poll_connect() never sees a dead pcb without its reason
|
||||
arg_this->connect_err_ = err == ERR_RST ? ECONNREFUSED : ETIMEDOUT;
|
||||
}
|
||||
arg_this->pcb_ = nullptr;
|
||||
esphome::wake_loop_any_context();
|
||||
}
|
||||
|
||||
err_t LWIPRawImpl::s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err) {
|
||||
// LWIP CALLBACK, same constraints as s_err_fn; err is always ERR_OK
|
||||
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
|
||||
arg_this->connect_err_ = EISCONN;
|
||||
esphome::wake_loop_any_context();
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) {
|
||||
LWIP_LOCK();
|
||||
if (this->pcb_ == nullptr) {
|
||||
errno = EBADF;
|
||||
return -1;
|
||||
}
|
||||
if (this->connect_err_ == EINPROGRESS || this->connect_err_ == EISCONN) {
|
||||
errno = this->connect_err_ == EINPROGRESS ? EALREADY : EISCONN;
|
||||
return -1;
|
||||
}
|
||||
ip_addr_t ip;
|
||||
uint16_t port;
|
||||
if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) {
|
||||
return -1;
|
||||
}
|
||||
#if LWIP_IPV6
|
||||
// tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped
|
||||
if (IP_IS_ANY_TYPE_VAL(ip)) {
|
||||
if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) {
|
||||
unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip));
|
||||
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4);
|
||||
} else {
|
||||
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
|
||||
err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn);
|
||||
if (err != ERR_OK) {
|
||||
LWIP_LOG(" -> err %d", err);
|
||||
errno = lwip_err_to_errno(err);
|
||||
return -1;
|
||||
}
|
||||
this->connect_err_ = EINPROGRESS;
|
||||
errno = EINPROGRESS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
ConnectPollResult LWIPRawImpl::poll_connect(int &err_out) const {
|
||||
// pcb_ first; see the ordering note on the declaration
|
||||
if (this->pcb_ == nullptr) {
|
||||
// Only a recorded connect failure carries its own reason
|
||||
const bool failed = this->connect_err_ == ECONNREFUSED || this->connect_err_ == ETIMEDOUT;
|
||||
err_out = failed ? this->connect_err_ : ECONNRESET;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
switch (this->connect_err_) {
|
||||
case EINPROGRESS:
|
||||
yield_to_sys(); // so the SYN-ACK is processed between polls
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
|
||||
case EISCONN:
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
|
||||
case 0:
|
||||
err_out = EINVAL; // no connect was started
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
default:
|
||||
err_out = this->connect_err_;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) {
|
||||
@@ -623,11 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
|
||||
}
|
||||
|
||||
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
|
||||
// Let queued WiFi RX reach lwip first; otherwise inbound segments can
|
||||
// sit unprocessed for seconds while the main loop polls
|
||||
#ifdef USE_ESP8266
|
||||
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
|
||||
// may succeed. Without this, inbound segments can sit unprocessed for
|
||||
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
|
||||
if (this->waiting_for_data_()) {
|
||||
yield_to_sys();
|
||||
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
|
||||
}
|
||||
#endif
|
||||
// See waiting_for_data_() for safety of unlocked reads.
|
||||
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
|
||||
this->wait_for_data_();
|
||||
@@ -716,10 +636,12 @@ int LWIPRawImpl::internal_output_() {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
#ifdef USE_ESP8266
|
||||
// Flushed: yield to SYS so the queued segments reach the WiFi driver
|
||||
// instead of waiting seconds for an unrelated SYS slot. Callers only get
|
||||
// here after a successful tcp_write, so idle paths never yield.
|
||||
yield_to_sys();
|
||||
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,6 @@ class LWIPRawCommon {
|
||||
|
||||
protected:
|
||||
int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen);
|
||||
/// sockaddr of this socket's family to lwip address and port; false with errno on mismatch
|
||||
bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const;
|
||||
|
||||
// Member ordering optimized to minimize padding on 32-bit systems
|
||||
struct tcp_pcb *pcb_;
|
||||
@@ -60,14 +58,7 @@ class LWIPRawCommon {
|
||||
bool nodelay_ = false;
|
||||
sa_family_t family_ = 0;
|
||||
uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s)
|
||||
// 0 before connect(), EINPROGRESS while pending, EISCONN once established,
|
||||
// else the failure errno the callbacks recorded; fills the padding byte
|
||||
uint8_t connect_err_ = 0;
|
||||
static_assert(EINPROGRESS < 256 && EISCONN < 256 && ECONNREFUSED < 256 && ECONNRESET < 256 && ETIMEDOUT < 256,
|
||||
"connect_err_ stores errno values in a byte");
|
||||
};
|
||||
// The connect state must stay in the padding so no socket pays RAM for it
|
||||
static_assert(sizeof(LWIPRawCommon) == sizeof(struct tcp_pcb *) + 4, "LWIPRawCommon grew past one word of flags");
|
||||
|
||||
/// Connected socket implementation for LWIP raw TCP.
|
||||
/// No virtual methods — callers always use the concrete type.
|
||||
@@ -92,12 +83,6 @@ class LWIPRawImpl : public LWIPRawCommon {
|
||||
errno = EOPNOTSUPP;
|
||||
return -1;
|
||||
}
|
||||
/// Non-blocking: returns -1/EINPROGRESS once the SYN is queued, see poll_connect().
|
||||
/// addr must match the socket family; an IPv4 peer on AF_INET6 arrives v4-mapped.
|
||||
int connect(const struct sockaddr *addr, socklen_t addrlen);
|
||||
// Unlocked like ready(): the callbacks write the error byte before pcb_,
|
||||
// so a torn read only costs one extra poll
|
||||
ConnectPollResult poll_connect(int &err_out) const;
|
||||
ssize_t read(void *buf, size_t len);
|
||||
ssize_t readv(const struct iovec *iov, int iovcnt);
|
||||
ssize_t recvfrom(void *, size_t, sockaddr *, socklen_t *) {
|
||||
@@ -135,7 +120,6 @@ class LWIPRawImpl : public LWIPRawCommon {
|
||||
|
||||
static void s_err_fn(void *arg, err_t err);
|
||||
static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err);
|
||||
static err_t s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err);
|
||||
|
||||
protected:
|
||||
// True when the socket could receive data but none has arrived yet.
|
||||
@@ -153,9 +137,6 @@ class LWIPRawImpl : public LWIPRawCommon {
|
||||
size_t rx_buf_offset_ = 0;
|
||||
bool rx_closed_ = false;
|
||||
};
|
||||
// rx_buf_, rx_buf_offset_, then rx_closed_ padded to a word
|
||||
static_assert(sizeof(LWIPRawImpl) == sizeof(LWIPRawCommon) + sizeof(pbuf *) + sizeof(size_t) + 4,
|
||||
"LWIPRawImpl layout changed");
|
||||
|
||||
/// Listening socket implementation for LWIP raw TCP.
|
||||
/// Separate from LWIPRawImpl — no virtual dispatch needed.
|
||||
|
||||
@@ -49,15 +49,13 @@ int LwIPSocketImpl::close() {
|
||||
|
||||
int LwIPSocketImpl::setblocking(bool blocking) {
|
||||
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
return lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||
lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
|
||||
#include <sys/select.h>
|
||||
#endif
|
||||
#include <string>
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -168,10 +165,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
||||
#else
|
||||
// Use LWIP-specific functions
|
||||
ip6_addr_t ip6;
|
||||
if (inet6_aton(ip_address, &ip6) == 0) {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
inet6_aton(ip_address, &ip6);
|
||||
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
|
||||
#endif
|
||||
return sizeof(sockaddr_in6);
|
||||
@@ -191,58 +185,12 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
// Unlike inet_addr(), inet_aton() can signal failure while still
|
||||
// accepting the broadcast address 255.255.255.255
|
||||
if (inet_aton(ip_address, &server->sin_addr) == 0) {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
server->sin_addr.s_addr = inet_addr(ip_address);
|
||||
#endif
|
||||
server->sin_port = htons(port);
|
||||
return sizeof(sockaddr_in);
|
||||
}
|
||||
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
ConnectPollResult poll_connect(Socket &sock, int &err_out) {
|
||||
int fd = sock.get_fd();
|
||||
if (fd < 0 || fd >= FD_SETSIZE) {
|
||||
// FD_SET on either is undefined behavior
|
||||
err_out = EBADF;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
// Connect completion is a write event; the main loop only selects on reads
|
||||
fd_set writefds;
|
||||
FD_ZERO(&writefds);
|
||||
FD_SET(fd, &writefds);
|
||||
struct timeval tv = {0, 0};
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
// LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly
|
||||
int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
#else
|
||||
// Global-scope select: the entity namespace esphome::select shadows it here
|
||||
int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv);
|
||||
#endif
|
||||
if (ret < 0) {
|
||||
err_out = errno;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
if (ret == 0) {
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
|
||||
}
|
||||
int error = 0;
|
||||
socklen_t len = sizeof(error);
|
||||
if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) {
|
||||
err_out = errno;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
if (error != 0) {
|
||||
err_out = error;
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
|
||||
}
|
||||
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
|
||||
}
|
||||
#endif
|
||||
|
||||
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
|
||||
#if USE_NETWORK_IPV6
|
||||
if (addrlen < sizeof(sockaddr_in6)) {
|
||||
|
||||
@@ -145,14 +145,6 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
|
||||
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
|
||||
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
|
||||
|
||||
/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or
|
||||
/// errno) on fd implementations and the failure the callbacks recorded on raw lwip.
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_TCP
|
||||
inline ConnectPollResult poll_connect(Socket &sock, int &err_out) { return sock.poll_connect(err_out); }
|
||||
#else
|
||||
ConnectPollResult poll_connect(Socket &sock, int &err_out);
|
||||
#endif
|
||||
|
||||
/// Format sockaddr into caller-provided buffer, returns length written (excluding null)
|
||||
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
|
||||
|
||||
|
||||
@@ -13,12 +13,7 @@ void UDPComponent::setup() {
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
for (const auto &address : this->addresses_) {
|
||||
struct sockaddr saddr {};
|
||||
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
|
||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
||||
// A dropped address silently receives nothing; surface the misconfiguration
|
||||
this->status_set_warning(LOG_STR("invalid address"));
|
||||
continue;
|
||||
}
|
||||
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
|
||||
this->sockaddrs_.push_back(saddr);
|
||||
}
|
||||
// set up broadcast socket
|
||||
@@ -99,11 +94,7 @@ void UDPComponent::setup() {
|
||||
// 8266 and RP2040 `Duino
|
||||
for (const auto &address : this->addresses_) {
|
||||
auto ipaddr = IPAddress();
|
||||
if (!ipaddr.fromString(address)) {
|
||||
ESP_LOGW(TAG, "Invalid address %s", address);
|
||||
this->status_set_warning(LOG_STR("invalid address"));
|
||||
continue;
|
||||
}
|
||||
ipaddr.fromString(address);
|
||||
this->ipaddrs_.push_back(ipaddr);
|
||||
}
|
||||
if (this->should_listen_)
|
||||
|
||||
@@ -434,12 +434,11 @@ void USBUartTypeCdcAcm::on_connected() {
|
||||
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
||||
channel->cdc_dev_.interrupt_interface_number, 0);
|
||||
if (err_comm != ESP_OK) {
|
||||
// Continue anyway: the interface number stays valid for CDC request addressing
|
||||
ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number,
|
||||
esp_err_to_name(err_comm));
|
||||
channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number);
|
||||
channel->cdc_dev_.interrupt_interface_claimed = true;
|
||||
}
|
||||
}
|
||||
auto err =
|
||||
@@ -466,15 +465,14 @@ void USBUartTypeCdcAcm::on_disconnected() {
|
||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
|
||||
}
|
||||
// Only tear down the notify pipe when we claimed its interface ourselves;
|
||||
// no transfer is ever submitted on it, so there is nothing else to cancel.
|
||||
if (channel->cdc_dev_.notify_ep != nullptr && channel->cdc_dev_.interrupt_interface_claimed) {
|
||||
if (channel->cdc_dev_.notify_ep != nullptr) {
|
||||
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
|
||||
}
|
||||
if (channel->cdc_dev_.interrupt_interface_claimed) {
|
||||
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
|
||||
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
|
||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number);
|
||||
channel->cdc_dev_.interrupt_interface_claimed = false;
|
||||
channel->cdc_dev_.interrupt_interface_number = 0xFF;
|
||||
}
|
||||
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number);
|
||||
// Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts
|
||||
|
||||
@@ -34,10 +34,7 @@ struct CdcEps {
|
||||
const usb_ep_desc_t *in_ep;
|
||||
const usb_ep_desc_t *out_ep;
|
||||
uint8_t bulk_interface_number;
|
||||
// Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it
|
||||
// must remain valid even when the interface itself is not claimed.
|
||||
uint8_t interrupt_interface_number;
|
||||
bool interrupt_interface_claimed{false};
|
||||
};
|
||||
|
||||
enum CH34xChipType : uint8_t {
|
||||
|
||||
@@ -34,10 +34,6 @@ void WakeOnLanButton::press_action() {
|
||||
struct sockaddr_storage saddr {};
|
||||
auto addr_len =
|
||||
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
|
||||
if (addr_len == 0) {
|
||||
ESP_LOGW(TAG, "Invalid broadcast address");
|
||||
return;
|
||||
}
|
||||
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
|
||||
memcpy(buffer, PREFIX, sizeof(PREFIX));
|
||||
for (size_t i = 0; i != 16; i++) {
|
||||
|
||||
@@ -23,6 +23,7 @@ from esphome.net_retry import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from filelock import FileLock
|
||||
import requests
|
||||
|
||||
PathType = str | os.PathLike
|
||||
@@ -909,6 +910,61 @@ def _part_path(dest: Path) -> Path:
|
||||
return dest.with_name(dest.name + ".part")
|
||||
|
||||
|
||||
def downloaded_bytes(dest: Path, size: int | None = None) -> int:
|
||||
"""Bytes of ``dest`` on disk (its ``.part`` while streaming), capped at ``size``."""
|
||||
done = 0
|
||||
for candidate in (_part_path(dest), dest):
|
||||
try:
|
||||
done = candidate.stat().st_size
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return done if size is None else min(done, size)
|
||||
|
||||
|
||||
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
|
||||
_DOWNLOAD_LOCK_POLL = 1
|
||||
|
||||
# Waiting on another process's download; past this the caller leaves the
|
||||
# file to its holder (the later sequential install waits on the same lock)
|
||||
DOWNLOAD_LOCK_TIMEOUT = 60
|
||||
|
||||
|
||||
class DownloadLockUnavailable(OSError):
|
||||
"""The lock file cannot be used at all (a lock-less filesystem)."""
|
||||
|
||||
|
||||
def wait_for_download_lock(
|
||||
lock: "FileLock",
|
||||
tracker: Callable[[int], None],
|
||||
on_disk: Callable[[], int],
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Acquire ``lock``, reporting ``on_disk()`` to ``tracker`` each poll so the
|
||||
bar follows the holder's download. Raises filelock's ``Timeout`` once
|
||||
``DOWNLOAD_LOCK_TIMEOUT`` seconds pass."""
|
||||
from filelock import Timeout
|
||||
|
||||
deadline = time.monotonic() + DOWNLOAD_LOCK_TIMEOUT
|
||||
waiting = False
|
||||
while True:
|
||||
try:
|
||||
lock.acquire(timeout=_DOWNLOAD_LOCK_POLL)
|
||||
return
|
||||
except Timeout:
|
||||
pass
|
||||
except OSError as err:
|
||||
# Distinct from an OSError out of on_disk(), which must not
|
||||
# read as "locks unsupported"
|
||||
raise DownloadLockUnavailable(*err.args) from err
|
||||
if not waiting:
|
||||
waiting = True
|
||||
_LOGGER.info("Waiting for another process downloading %s", name)
|
||||
tracker(on_disk()) # raises when the batch is cancelled
|
||||
if time.monotonic() >= deadline:
|
||||
raise Timeout(lock.lock_file)
|
||||
|
||||
|
||||
def discard_partial_download(dest: Path) -> None:
|
||||
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
|
||||
part = _part_path(dest)
|
||||
@@ -1319,10 +1375,7 @@ def download_from_mirrors(
|
||||
)
|
||||
# Tick with the bytes already on disk so a combined bar holds
|
||||
# steady during the backoff instead of rewinding to zero
|
||||
done = 0
|
||||
if progress is not None:
|
||||
part = _part_path(path_target)
|
||||
done = part.stat().st_size if part.is_file() else 0
|
||||
done = downloaded_bytes(path_target) if progress is not None else 0
|
||||
_cancellable_sleep(delay, progress, done)
|
||||
|
||||
# 3. Report every attempted URL if all mirrors failed. failures spans
|
||||
|
||||
@@ -33,11 +33,14 @@ import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from esphome.framework_helpers import (
|
||||
DownloadLockUnavailable,
|
||||
content_length,
|
||||
discard_partial_download,
|
||||
downloaded_bytes,
|
||||
failure_reason,
|
||||
resume_fetch_job,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
||||
@@ -61,16 +64,10 @@ _RESOLVE_WORKERS = 8
|
||||
# A hung child must not block the build; downloads resume on the next run
|
||||
_PREFETCH_TIMEOUT = 20 * 60
|
||||
|
||||
# Waiting on another process's URL download; past this, leave it to pio
|
||||
_DOWNLOAD_LOCK_TIMEOUT = 60
|
||||
|
||||
# Child exit for a handled, already-warned failure; 1 would collide with
|
||||
# the interpreter's own import-failure exit
|
||||
_EXIT_HANDLED = 3
|
||||
|
||||
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
|
||||
_URI_LOCK_POLL = 1
|
||||
|
||||
# Resolution errored (vs a clean skip); suppresses the warm sentinel
|
||||
_RESOLVE_FAILED = object()
|
||||
|
||||
@@ -462,51 +459,54 @@ def _uri_jobs(
|
||||
|
||||
|
||||
def _serialized_fetch_job(
|
||||
dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True
|
||||
dl_path: Path,
|
||||
lock_path: str,
|
||||
body: Any,
|
||||
size: int,
|
||||
stream_dest: Path | None = None,
|
||||
unlocked_ok: bool = True,
|
||||
) -> Any:
|
||||
"""Wrap ``body`` so the shared destination is single-writer.
|
||||
|
||||
Interleaved writers truncate each other's ``.part`` bytes (see
|
||||
registry.py). The bounded poll observes Ctrl-C via the tracker; a
|
||||
blown deadline is a clean skip (the holder's copy is what the build
|
||||
needs). On a lock-less filesystem a sha256-verified body runs
|
||||
unlocked with one warning; a checksum-less one
|
||||
(``unlocked_ok=False``) is a counted failure instead.
|
||||
"""Wrap ``body`` so the shared destination is single-writer (interleaved
|
||||
writers truncate each other's ``.part``, see registry.py). A blown deadline
|
||||
is a clean skip. On a lock-less filesystem a sha256-verified body runs
|
||||
unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails.
|
||||
"""
|
||||
|
||||
def on_disk() -> int:
|
||||
# A URL job's holder streams beside the staging path until it
|
||||
# promotes; after that only dl_path is left
|
||||
done = downloaded_bytes(dl_path, size)
|
||||
if not done and stream_dest is not None:
|
||||
done = downloaded_bytes(stream_dest, size)
|
||||
return done
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
# fallback_to_soft would leave a stale marker on lock-less
|
||||
# filesystems that blocks every later build (see git.py)
|
||||
lock = FileLock(lock_path, fallback_to_soft=False)
|
||||
deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT
|
||||
while True:
|
||||
try:
|
||||
lock.acquire(timeout=_URI_LOCK_POLL)
|
||||
break
|
||||
except Timeout:
|
||||
tracker(0) # raises when the batch is cancelled
|
||||
if time.monotonic() >= deadline:
|
||||
# Another process is fetching this same file; its copy
|
||||
# is what the build needs (a large framework archive
|
||||
# can hold the lock far longer than this deadline)
|
||||
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
|
||||
return
|
||||
except OSError as err:
|
||||
if not unlocked_ok:
|
||||
# A body with no checksum to catch interleaved corruption
|
||||
raise
|
||||
lock = None
|
||||
_LOGGER.warning(
|
||||
"Could not lock %s (%s); downloading unlocked",
|
||||
dl_path.name,
|
||||
err,
|
||||
)
|
||||
break
|
||||
try:
|
||||
wait_for_download_lock(lock, tracker, on_disk, dl_path.name)
|
||||
except Timeout:
|
||||
# The holder's copy is what the build needs (a large
|
||||
# framework archive can outlast this deadline)
|
||||
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
|
||||
return
|
||||
except DownloadLockUnavailable as err:
|
||||
if not unlocked_ok:
|
||||
# A body with no checksum to catch interleaved corruption
|
||||
raise
|
||||
lock = None
|
||||
_LOGGER.warning(
|
||||
"Could not lock %s (%s); downloading unlocked",
|
||||
dl_path.name,
|
||||
err,
|
||||
)
|
||||
try:
|
||||
if dl_path.is_file():
|
||||
return # another process finished it while we waited
|
||||
tracker(size) # another process finished it while we waited
|
||||
return
|
||||
body(tracker)
|
||||
finally:
|
||||
if lock is not None:
|
||||
@@ -540,6 +540,7 @@ def _registry_fetch_job(
|
||||
dl_path,
|
||||
f"{dl_path}.esphome.lock",
|
||||
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
|
||||
size,
|
||||
)
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
@@ -571,9 +572,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
|
||||
tmp.replace(dl_path)
|
||||
|
||||
def run(tracker: Any) -> None:
|
||||
_serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)(
|
||||
tracker
|
||||
)
|
||||
_serialized_fetch_job(
|
||||
dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False
|
||||
)(tracker)
|
||||
if dl_path.is_file():
|
||||
# Won or lost, the race is over; staging files left behind
|
||||
# are dead weight PlatformIO's cache never prunes
|
||||
|
||||
@@ -17,8 +17,10 @@ from esphome.framework_helpers import (
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
downloaded_bytes,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
)
|
||||
from esphome.net_retry import fetch_with_retry, http_request
|
||||
|
||||
@@ -164,11 +166,17 @@ class _PendingArchive(NamedTuple):
|
||||
name: str
|
||||
version: str
|
||||
dest: Path
|
||||
archive: Path
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
|
||||
|
||||
def _archive_path(downloads_dir: Path, name: str, version: str) -> Path:
|
||||
"""The one archive path the prefetch and the sequential install share."""
|
||||
return downloads_dir / f"{name}-{version}"
|
||||
|
||||
|
||||
def _already_installed(dest: Path) -> bool:
|
||||
"""Whether ``dest`` holds a completed install (extraction marker)."""
|
||||
return (dest / ".esphome_extracted").is_file()
|
||||
@@ -187,18 +195,18 @@ def prefetch_packages(
|
||||
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
||||
two concurrent writers would truncate each other's bytes.
|
||||
"""
|
||||
from filelock import FileLock
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
pending: list[_PendingArchive] = []
|
||||
seen: set[str] = set()
|
||||
seen: set[Path] = set()
|
||||
for name, version, dest, mirrors in packages:
|
||||
if mirrors or (dest / ".esphome_extracted").is_file():
|
||||
continue
|
||||
archive_name = f"{name}-{version}"
|
||||
if archive_name in seen:
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
if archive in seen:
|
||||
# A duplicate entry would race itself between two workers
|
||||
continue
|
||||
seen.add(archive_name)
|
||||
seen.add(archive)
|
||||
try:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
except EsphomeError as err:
|
||||
@@ -207,10 +215,9 @@ def prefetch_packages(
|
||||
continue
|
||||
if not size:
|
||||
continue
|
||||
archive = downloads_dir / archive_name
|
||||
if archive.is_file() and archive.stat().st_size == size:
|
||||
continue
|
||||
pending.append(_PendingArchive(name, version, dest, url, sha256, size))
|
||||
pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size))
|
||||
if len(pending) < 2:
|
||||
return
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -222,20 +229,36 @@ def prefetch_packages(
|
||||
|
||||
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
|
||||
entry.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FileLock(f"{entry.dest}.lock", fallback_to_soft=False):
|
||||
# Marker re-check: a concurrent build may have installed (and
|
||||
# deleted the archive of) this package while we waited;
|
||||
# re-downloading would orphan a fresh copy in downloads_dir
|
||||
# no branch: the thread tracer misses the skip edge; both
|
||||
# arms of _already_installed are pinned directly
|
||||
if not _already_installed(entry.dest): # pragma: no branch
|
||||
download_with_resume(
|
||||
entry.url,
|
||||
downloads_dir / f"{entry.name}-{entry.version}",
|
||||
sha256=entry.sha256,
|
||||
size=entry.size,
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
def on_disk() -> int:
|
||||
if done := downloaded_bytes(entry.archive, entry.size):
|
||||
return done
|
||||
# The holder deletes the archive once it has installed it
|
||||
return entry.size if _already_installed(entry.dest) else 0
|
||||
|
||||
lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False)
|
||||
try:
|
||||
wait_for_download_lock(lock, tracker, on_disk, entry.name)
|
||||
except Timeout:
|
||||
# install_package waits on this same lock and verifies the
|
||||
# holder's copy
|
||||
_LOGGER.debug("Leaving %s to its current downloader", entry.name)
|
||||
return
|
||||
try:
|
||||
if _already_installed(entry.dest):
|
||||
# A concurrent build installed it while we waited; a
|
||||
# re-download would orphan a fresh copy in downloads_dir
|
||||
tracker(entry.size)
|
||||
return
|
||||
download_with_resume(
|
||||
entry.url,
|
||||
entry.archive,
|
||||
sha256=entry.sha256,
|
||||
size=entry.size,
|
||||
progress=tracker,
|
||||
)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
failures = run_batch_downloads(
|
||||
"Downloading packages",
|
||||
@@ -288,7 +311,7 @@ def install_package(
|
||||
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = downloads_dir / f"{name}-{version}"
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
substitutions:
|
||||
network_enable_ipv6: "true"
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -1,17 +0,0 @@
|
||||
esphome:
|
||||
name: socket-set-sockaddr
|
||||
on_boot:
|
||||
then:
|
||||
- lambda: |-
|
||||
// 0 for text that is not an address, the length otherwise, broadcast included
|
||||
struct sockaddr_storage addr;
|
||||
auto *sa = reinterpret_cast<struct sockaddr *>(&addr);
|
||||
ESP_LOGI("test", "SET_SOCKADDR invalid=%u valid=%u broadcast=%u",
|
||||
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "not an address", 1234),
|
||||
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "192.0.2.1", 1234),
|
||||
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "255.255.255.255", 1234));
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: INFO
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Integration test for the socket::set_sockaddr failure contract."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_set_sockaddr(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""set_sockaddr reports an invalid address with 0 and accepts broadcast."""
|
||||
loop = asyncio.get_running_loop()
|
||||
result: asyncio.Future[tuple[int, int, int]] = loop.create_future()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
match = re.search(
|
||||
r"SET_SOCKADDR invalid=(\d+) valid=(\d+) broadcast=(\d+)", line
|
||||
)
|
||||
if match and not result.done():
|
||||
result.set_result(tuple(int(g) for g in match.groups()))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
assert (await client.device_info()).name == "socket-set-sockaddr"
|
||||
try:
|
||||
invalid, valid, broadcast = await asyncio.wait_for(result, timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("SET_SOCKADDR marker never appeared")
|
||||
|
||||
assert invalid == 0
|
||||
assert valid > 0
|
||||
assert broadcast == valid
|
||||
@@ -9,7 +9,7 @@ not be part of a unit test suite.
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -137,3 +137,40 @@ def mock_get_component() -> Generator[Mock, None, None]:
|
||||
"""Mock get_component for config module."""
|
||||
with patch("esphome.config.get_component") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def held_lock() -> Callable[..., Callable[..., None]]:
|
||||
"""Factory for a ``FileLock.acquire`` fake held by another downloader.
|
||||
|
||||
Each poll writes the next chunk to ``part`` (or runs it, for a callable)
|
||||
and raises ``Timeout``; when the chunks run out the part is removed,
|
||||
``land()`` runs, and the acquire succeeds (also for any later job, so
|
||||
``land`` must be idempotent).
|
||||
"""
|
||||
from filelock import Timeout
|
||||
|
||||
def make(
|
||||
part: Path,
|
||||
chunks: list[bytes | Callable[[], None]],
|
||||
land: Callable[[], None],
|
||||
) -> Callable[..., None]:
|
||||
polls = iter(chunks)
|
||||
|
||||
def acquire(*args, **kwargs) -> None:
|
||||
try:
|
||||
chunk = next(polls)
|
||||
except StopIteration:
|
||||
part.unlink(missing_ok=True)
|
||||
land()
|
||||
return
|
||||
if callable(chunk):
|
||||
chunk()
|
||||
else:
|
||||
part.parent.mkdir(parents=True, exist_ok=True)
|
||||
part.write_bytes(chunk)
|
||||
raise Timeout("held")
|
||||
|
||||
return acquire
|
||||
|
||||
return make
|
||||
|
||||
@@ -2353,3 +2353,20 @@ def test_discard_partial_download_logs_undeletable(
|
||||
):
|
||||
framework_helpers.discard_partial_download(dest)
|
||||
assert "Could not remove" in caplog.text
|
||||
|
||||
|
||||
def test_downloaded_bytes_reports_what_is_on_disk(tmp_path: Path) -> None:
|
||||
"""Part file first, then the landed file, both capped at size; else 0."""
|
||||
dest = tmp_path / "archive"
|
||||
assert framework_helpers.downloaded_bytes(dest, 4) == 0
|
||||
part = tmp_path / "archive.part"
|
||||
part.write_bytes(b"ab")
|
||||
assert framework_helpers.downloaded_bytes(dest, 4) == 2
|
||||
part.write_bytes(b"abcdef")
|
||||
assert framework_helpers.downloaded_bytes(dest, 4) == 4
|
||||
part.unlink()
|
||||
dest.write_bytes(b"abc")
|
||||
assert framework_helpers.downloaded_bytes(dest, 4) == 3
|
||||
assert framework_helpers.downloaded_bytes(dest) == 3
|
||||
dest.write_bytes(b"abcdef")
|
||||
assert framework_helpers.downloaded_bytes(dest, 4) == 4
|
||||
|
||||
@@ -454,23 +454,96 @@ def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None:
|
||||
assert dl_path.read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None:
|
||||
"""A lock held past the deadline means another process is fetching the
|
||||
same file; skipping cleanly beats a misleading failure warning. The
|
||||
tracker is still polled so a parked worker observes cancellation."""
|
||||
@pytest.mark.parametrize("staged", [b"", b"ab"])
|
||||
def test_lock_deadline_leaves_download_to_the_holder(
|
||||
tmp_path: Path, staged: bytes
|
||||
) -> None:
|
||||
"""A lock held past the deadline is another process's download; skip
|
||||
cleanly, polling the tracker with what the holder has staged so far."""
|
||||
dl_path = tmp_path / "archive"
|
||||
(tmp_path / "archive.prefetch.part").write_bytes(staged)
|
||||
ticks: list[int] = []
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
|
||||
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append)
|
||||
mock_download.assert_not_called()
|
||||
assert ticks == [0]
|
||||
assert ticks == [len(staged)]
|
||||
assert not dl_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job", "part_name", "chunks", "expected"),
|
||||
[
|
||||
(
|
||||
lambda dl_path: pf._registry_fetch_job(
|
||||
MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4
|
||||
),
|
||||
"archive.part",
|
||||
[b"a", b"abc"],
|
||||
[1, 3, 4],
|
||||
),
|
||||
(
|
||||
lambda dl_path: pf._uri_fetch_job(
|
||||
MagicMock(), "https://x/a.zip", dl_path, 4
|
||||
),
|
||||
"archive.prefetch.part",
|
||||
[b"ab"],
|
||||
[2, 4],
|
||||
),
|
||||
],
|
||||
ids=["registry", "uri"],
|
||||
)
|
||||
def test_lock_wait_reports_the_holders_progress(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
held_lock,
|
||||
job,
|
||||
part_name: str,
|
||||
chunks: list[bytes],
|
||||
expected: list[int],
|
||||
) -> None:
|
||||
"""A waiting job reports the holder's part file (the staging one for a
|
||||
URL job), then the full size once the holder lands the archive."""
|
||||
dl_path = tmp_path / "archive"
|
||||
ticks: list[int] = []
|
||||
acquire = held_lock(
|
||||
tmp_path / part_name, chunks, lambda: dl_path.write_bytes(b"abcd")
|
||||
)
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=acquire),
|
||||
patch("filelock.FileLock.release"),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
job(dl_path)(ticks.append)
|
||||
mock_download.assert_not_called()
|
||||
assert ticks == expected
|
||||
assert caplog.text.count("Waiting for another process downloading archive") == 1
|
||||
|
||||
|
||||
def test_uri_lock_wait_prefers_the_landed_archive(tmp_path: Path, held_lock) -> None:
|
||||
"""Between the holder's promotion rename and its release the staging
|
||||
part is gone; the landed cache file is credited instead of 0."""
|
||||
dl_path = tmp_path / "archive"
|
||||
ticks: list[int] = []
|
||||
acquire = held_lock(
|
||||
tmp_path / "archive.prefetch.part",
|
||||
[b"ab", lambda: dl_path.write_bytes(b"abcd")],
|
||||
lambda: None,
|
||||
)
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=acquire),
|
||||
patch("filelock.FileLock.release"),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append)
|
||||
mock_download.assert_not_called()
|
||||
assert ticks == [2, 4, 4]
|
||||
|
||||
|
||||
def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None:
|
||||
"""A registry job that lost the download race to another process
|
||||
must not stamp a nonexistent archive into pio's usage.db."""
|
||||
@@ -479,7 +552,7 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
|
||||
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
):
|
||||
pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)(
|
||||
lambda done: None
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from filelock import Timeout
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
@@ -540,16 +541,13 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "a"
|
||||
dest.mkdir()
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def marker_appears_under_lock(path, **kwargs):
|
||||
def marker_appears_under_lock(*args, **kwargs):
|
||||
# Simulates the concurrent build finishing while we waited
|
||||
(dest / ".esphome_extracted").touch()
|
||||
yield
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock", side_effect=marker_appears_under_lock),
|
||||
patch("filelock.FileLock.acquire", side_effect=marker_appears_under_lock),
|
||||
patch("filelock.FileLock.release"),
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
@@ -559,6 +557,69 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None:
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_waits_with_the_holders_progress(
|
||||
tmp_path: Path, held_lock
|
||||
) -> None:
|
||||
"""A worker parked on another build's lock reports that build's part
|
||||
file, then the full size once the marker appears."""
|
||||
dest = tmp_path / "a"
|
||||
dest.mkdir()
|
||||
ticks: list[int] = []
|
||||
part = tmp_path / "dl" / "a-1.0.part"
|
||||
|
||||
def installed_and_pruned() -> None:
|
||||
# install_package touches the marker, then unlinks the archive
|
||||
(dest / ".esphome_extracted").touch()
|
||||
part.unlink()
|
||||
|
||||
acquire = held_lock(
|
||||
part,
|
||||
[lambda: None, b"abc", installed_and_pruned],
|
||||
(dest / ".esphome_extracted").touch,
|
||||
)
|
||||
|
||||
def fake_batch(header, jobs):
|
||||
for _name, _size, fetch in jobs:
|
||||
fetch(ticks.append)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock.acquire", side_effect=acquire),
|
||||
patch("filelock.FileLock.release"),
|
||||
patch.object(registry, "run_batch_downloads", side_effect=fake_batch),
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert ticks == [0, 3, 10, 10]
|
||||
mock_download.assert_called_once()
|
||||
|
||||
|
||||
def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Past the deadline the worker skips; install_package waits on the same
|
||||
lock later and verifies whatever the holder produced."""
|
||||
with (
|
||||
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
|
||||
patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_already_installed_probe(tmp_path: Path) -> None:
|
||||
"""Both arms of the marker probe the prefetch worker keys on."""
|
||||
dest = tmp_path / "pkg"
|
||||
|
||||
Reference in New Issue
Block a user