Summary
The local HTTP server accepts and services exactly one client at a time. When two requests arrive concurrently, one of them reliably fails. On an ESP8266 the failure is not a slow response but a lost request, so callers see it as a connection error rather than as a busy server.
Measured against a real device running 0.2.2 (commit e792f75), which is what the OpenSprinkler firmware currently pins. I re-read the same code path in 0.2.4 afterwards: the behaviour described here is unchanged there, so the line numbers below refer to 0.2.4.
Environment
- Measured on library version 0.2.2, commit
e792f75, used as the local server in an OpenSprinkler firmware build. Code analysis re-checked against 0.2.4.
- Target: OpenSprinkler with ESP8266
- Endpoint under test:
/db (any registered handler works; nothing about this endpoint is special)
- Client:
curl, 6 s timeout per request, from a machine on the same LAN
Observed behaviour
Two runs against the same device, back to back, same network, same endpoint:
| Run |
Requests |
Successful |
Failed |
| 12 requests strictly sequential |
12 |
11 |
1 |
| 12 rounds of 2 concurrent requests |
24 |
12 |
12 |
The concurrent run was not randomly degraded: in every one of the 12 rounds, exactly one request succeeded and exactly one failed with a connection error. The split was 1:1 in each round without exception.
An ICMP ping ran continuously for the duration of both runs with zero packet loss, so the device stayed reachable on the network throughout. The failures are therefore not link-level loss.
Reproduction
IP=<device-ip>
URL="http://$IP/db" # or any path registered via otf->on(...)
# Baseline: strictly sequential
for i in $(seq 1 12); do
curl -sS -m 6 -o /dev/null -w "seq %{http_code}\n" "$URL" || echo "seq FAIL"
done
# Two concurrent requests per round
for i in $(seq 1 12); do
( curl -sS -m 6 -o /dev/null "$URL" && echo "a ok" || echo "a FAIL" ) &
( curl -sS -m 6 -o /dev/null "$URL" && echo "b ok" || echo "b FAIL" ) &
wait
done
Running ping $IP in parallel confirms the device itself stays reachable while the requests fail.
Analysis
Three properties of the current local-server loop combine here. All line numbers are from 0.2.4.
1. There is exactly one client slot, and accepting a new client destroys the previous one.
OpenThingsFramework holds a single client pointer (OpenThingsFramework.h:59), and Esp8266LocalServer::acceptClient() deletes the previously held client before accepting the next one (Esp8266LocalServer.cpp:8-20, specifically the delete activeClient at lines 9-11). A second connection therefore cannot be read from, or even held, while the first transaction is in progress.
2. While a client is held but silent, no other client is accepted at all.
In localServerLoop(), wait_to is a function-local static (OpenThingsFramework.cpp:127) that does double duty: it is both the deadline for the currently held client and the flag for "no client is currently held". acceptClient() sits inside if (!wait_to) (OpenThingsFramework.cpp:134-141), so once wait_to is set it is unreachable. If the held client has not sent data yet, the function returns early at line 152 and does so on every subsequent iteration until the deadline expires, up to WIFI_CONNECTION_TIMEOUT, defined as 1500 ms at line 14. Only then is the silent client flushed and stopped (lines 146-151) and the slot released. During that entire window the server accepts nobody, no matter how many connections are waiting.
3. During that window the cloud websocket is not polled either.
localServerLoop() returns true in exactly that "holding a silent client" state (line 152). loop() only calls webSocket->poll() when the local loop returned false (OpenThingsFramework.cpp:331-336, condition at line 333). So a single silent or slow local client also stalls cloud-forwarded request handling for the same window.
Two smaller things noticed in the same state machine while tracing this:
- On the 413 path in 0.2.2, the client was stopped and a new one accepted while
wait_to was still 0, so the next iteration accepted again and dropped that client unread. This is already fixed in 0.2.4 by closeCurrentClient() (OpenThingsFramework.cpp:128-133), which sets wait_to. Mentioning it only so it is clear I checked.
- Because
wait_to == 0 is the sentinel for "no client held", a deadline computed as millis()+WIFI_CONNECTION_TIMEOUT (line 140) that happens to land exactly on 0 across the millis() rollover would make the loop treat a held client as absent. Astronomically rare, but it is the same one-variable-two-jobs pattern.
The 1:1 success/failure split in the measurement is consistent with the single client slot: one connection occupies it, the other is not held anywhere in the library while that transaction runs.
Practical impact
This is not a synthetic condition. A typical installation has a phone app open and a home-automation integration (Home Assistant, ioBroker, Node-RED, a polling script) querying the same device on a timer. Those two clients collide regularly and, from that moment on, one of them times out on a large share of its requests.
From the user's side the symptom is intermittent, unreproducible-on-demand timeouts against a device that answers ping perfectly and responds fine when tested by hand. That reads exactly like a flaky Wi-Fi link, so the time gets spent on channel changes, repeaters, static leases, and reflashing, none of which help. The concurrency is invisible to the user because the second client is usually an integration polling in the background, so the connection between "the app misbehaves" and "something else polled at the same moment" is not one anybody makes without seeing the code.
Where a fix would have to attach
Deliberately not proposing a design; the right trade-off on a device with this little RAM is yours to make. The places any fix would have to touch:
- The acceptance gate in
localServerLoop() (OpenThingsFramework.cpp:134), which currently couples "may I accept a new client" to "is no client currently pending".
- The single client slot:
localClient in OpenThingsFramework.h:59 together with Esp8266LocalServer::acceptClient() (Esp8266LocalServer.cpp:8-20), which frees the previous client as a side effect of accepting the next, so the two cannot coexist. The ESP32 and Linux backends are structured the same way.
- The return value of
localServerLoop() that gates webSocket->poll() (OpenThingsFramework.cpp:152 and 328, consumed at line 333), if the goal includes not stalling cloud traffic behind a slow local client.
Summary
The local HTTP server accepts and services exactly one client at a time. When two requests arrive concurrently, one of them reliably fails. On an ESP8266 the failure is not a slow response but a lost request, so callers see it as a connection error rather than as a busy server.
Measured against a real device running 0.2.2 (commit
e792f75), which is what the OpenSprinkler firmware currently pins. I re-read the same code path in 0.2.4 afterwards: the behaviour described here is unchanged there, so the line numbers below refer to 0.2.4.Environment
e792f75, used as the local server in an OpenSprinkler firmware build. Code analysis re-checked against 0.2.4./db(any registered handler works; nothing about this endpoint is special)curl, 6 s timeout per request, from a machine on the same LANObserved behaviour
Two runs against the same device, back to back, same network, same endpoint:
The concurrent run was not randomly degraded: in every one of the 12 rounds, exactly one request succeeded and exactly one failed with a connection error. The split was 1:1 in each round without exception.
An ICMP ping ran continuously for the duration of both runs with zero packet loss, so the device stayed reachable on the network throughout. The failures are therefore not link-level loss.
Reproduction
Running
ping $IPin parallel confirms the device itself stays reachable while the requests fail.Analysis
Three properties of the current local-server loop combine here. All line numbers are from 0.2.4.
1. There is exactly one client slot, and accepting a new client destroys the previous one.
OpenThingsFrameworkholds a single client pointer (OpenThingsFramework.h:59), andEsp8266LocalServer::acceptClient()deletes the previously held client before accepting the next one (Esp8266LocalServer.cpp:8-20, specifically thedelete activeClientat lines 9-11). A second connection therefore cannot be read from, or even held, while the first transaction is in progress.2. While a client is held but silent, no other client is accepted at all.
In
localServerLoop(),wait_tois a function-localstatic(OpenThingsFramework.cpp:127) that does double duty: it is both the deadline for the currently held client and the flag for "no client is currently held".acceptClient()sits insideif (!wait_to)(OpenThingsFramework.cpp:134-141), so oncewait_tois set it is unreachable. If the held client has not sent data yet, the function returns early at line 152 and does so on every subsequent iteration until the deadline expires, up toWIFI_CONNECTION_TIMEOUT, defined as 1500 ms at line 14. Only then is the silent client flushed and stopped (lines 146-151) and the slot released. During that entire window the server accepts nobody, no matter how many connections are waiting.3. During that window the cloud websocket is not polled either.
localServerLoop()returnstruein exactly that "holding a silent client" state (line 152).loop()only callswebSocket->poll()when the local loop returnedfalse(OpenThingsFramework.cpp:331-336, condition at line 333). So a single silent or slow local client also stalls cloud-forwarded request handling for the same window.Two smaller things noticed in the same state machine while tracing this:
wait_towas still 0, so the next iteration accepted again and dropped that client unread. This is already fixed in 0.2.4 bycloseCurrentClient()(OpenThingsFramework.cpp:128-133), which setswait_to. Mentioning it only so it is clear I checked.wait_to == 0is the sentinel for "no client held", a deadline computed asmillis()+WIFI_CONNECTION_TIMEOUT(line 140) that happens to land exactly on 0 across themillis()rollover would make the loop treat a held client as absent. Astronomically rare, but it is the same one-variable-two-jobs pattern.The 1:1 success/failure split in the measurement is consistent with the single client slot: one connection occupies it, the other is not held anywhere in the library while that transaction runs.
Practical impact
This is not a synthetic condition. A typical installation has a phone app open and a home-automation integration (Home Assistant, ioBroker, Node-RED, a polling script) querying the same device on a timer. Those two clients collide regularly and, from that moment on, one of them times out on a large share of its requests.
From the user's side the symptom is intermittent, unreproducible-on-demand timeouts against a device that answers ping perfectly and responds fine when tested by hand. That reads exactly like a flaky Wi-Fi link, so the time gets spent on channel changes, repeaters, static leases, and reflashing, none of which help. The concurrency is invisible to the user because the second client is usually an integration polling in the background, so the connection between "the app misbehaves" and "something else polled at the same moment" is not one anybody makes without seeing the code.
Where a fix would have to attach
Deliberately not proposing a design; the right trade-off on a device with this little RAM is yours to make. The places any fix would have to touch:
localServerLoop()(OpenThingsFramework.cpp:134), which currently couples "may I accept a new client" to "is no client currently pending".localClientinOpenThingsFramework.h:59together withEsp8266LocalServer::acceptClient()(Esp8266LocalServer.cpp:8-20), which frees the previous client as a side effect of accepting the next, so the two cannot coexist. The ESP32 and Linux backends are structured the same way.localServerLoop()that gateswebSocket->poll()(OpenThingsFramework.cpp:152and328, consumed at line 333), if the goal includes not stalling cloud traffic behind a slow local client.