Skip to content

Local HTTP server handles only one client at a time — one of two concurrent requests fails deterministically (ESP8266, 0.2.2) #16

Description

@cl445

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions