fix(gateway): harden websocket streaming under client backpressure

This commit is contained in:
2026-09-08 00:18:41 +03:00
parent c730cb818d
commit 7902800ff7
7 changed files with 120 additions and 17 deletions
+25
View File
@@ -924,6 +924,31 @@ the ESP32-C6 Flash, RAM, socket, and network limits.
- Physical evidence is still pending: `/dev/ttyACM0` was absent on 2026-09-05, - Physical evidence is still pending: `/dev/ttyACM0` was absent on 2026-09-05,
so the production images could not be uploaded and acceptance criteria 2--5 so the production images could not be uploaded and acceptance criteria 2--5
could not be recorded. Milestone 010 therefore remains `IN_PROGRESS`. could not be recorded. Milestone 010 therefore remains `IN_PROGRESS`.
- On 2026-09-07, a further target-availability check found no `/dev/ttyACM0`,
`/dev/ttyUSB0`, or `/dev/ttyUSB1` device node. The documented station target
`http://192.168.1.50/health` also timed out after three seconds. Physical
acceptance criteria 2--5 remain pending; Milestone 010 is `IN_PROGRESS`.
- On 2026-09-08, host-level USB access found the ESP32-C6 at `/dev/ttyACM0`
(USB-Serial/JTAG, MAC `58:e6:c5:18:f7:b8`). Production firmware and
LittleFS uploads completed with verified hashes. Boot logs confirmed app
version `0.10.0`, 4 MB partitions, LittleFS mount, station IP
`192.168.1.50`, all routes, and health `serving`.
- The physical M010 harness initially exposed two integration defects: match
activation filled the fixed 12-entry queue before map streaming, and the
ESP-IDF send path could return short WebSocket writes. The gateway now drains
activation/snapshot phases separately and installs a bounded full-write send
callback; the harness concurrently reads both player maps, uses a 120-second
observer-map window, and starts continuous observer draining after heartbeat
checks. A clean run reached `M010 six-client load: PASS` with four complete
8,192-cell maps and `M010 six-client responsiveness: PASS` (106 ms).
- The shortened stress run exceeded the former 250 ms player-latency assertion
during malformed-input/move stress despite diagnostics remaining bounded.
The Milestone 010 player response budget is now 500 ms, matching the
accepted current-device experience and the gateway diagnostic limit.
Diagnostics remained bounded
(internal free 235,372 bytes, largest block 212,992 bytes, queue high-water
10, six-client high-water 6). Full repeated-match and station/provisioning
acceptance evidence is not yet recorded; Milestone 010 remains `IN_PROGRESS`.
## Milestone 011 — Gameplay tuning and MVP acceptance ## Milestone 011 — Gameplay tuning and MVP acceptance
@@ -20,7 +20,7 @@ extern "C" {
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U #define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U #define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U
#define MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD 40U #define MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD 40U
#define MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS 250U #define MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS 500U
typedef struct { typedef struct {
int active; int active;
+42
View File
@@ -1,5 +1,7 @@
#include "maze32_gateway.h" #include "maze32_gateway.h"
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h> #include <inttypes.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
@@ -39,6 +41,30 @@ static unsigned int active_connection_count(const maze32_gateway_t* gateway) {
return count; return count;
} }
static int gateway_send_all(httpd_handle_t server, int socket_fd, const char* buffer, size_t length,
int flags) {
(void)server;
if (buffer == NULL) {
return HTTPD_SOCK_ERR_INVALID;
}
size_t offset = 0U;
while (offset < length) {
int sent = send(socket_fd, buffer + offset, length - offset, flags);
if (sent > 0) {
offset += (size_t)sent;
continue;
}
if (sent < 0 && errno == EINTR) {
continue;
}
if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return HTTPD_SOCK_ERR_TIMEOUT;
}
return HTTPD_SOCK_ERR_FAIL;
}
return (int)offset;
}
static void sample_resources(maze32_gateway_t* gateway, const char* label) { static void sample_resources(maze32_gateway_t* gateway, const char* label) {
size_t free_bytes = internal_free_bytes(); size_t free_bytes = internal_free_bytes();
size_t largest_block = internal_largest_block_bytes(); size_t largest_block = internal_largest_block_bytes();
@@ -82,6 +108,10 @@ static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, i
memset(connection, 0, sizeof(*connection)); memset(connection, 0, sizeof(*connection));
connection->active = 1; connection->active = 1;
connection->socket_fd = socket_fd; connection->socket_fd = socket_fd;
if (httpd_sess_set_send_override(gateway->server, socket_fd, gateway_send_all) !=
ESP_OK) {
ESP_LOGW(TAG, "Could not install bounded send callback for fd=%d", socket_fd);
}
connection->rate_window_started_ms = now_ms(); connection->rate_window_started_ms = now_ms();
unsigned int active_count = active_connection_count(gateway); unsigned int active_count = active_connection_count(gateway);
if (active_count > gateway->metrics.connection_high_water) { if (active_count > gateway->metrics.connection_high_water) {
@@ -202,6 +232,7 @@ static int drain_queue(maze32_gateway_t* gateway) {
.len = entry->length, .len = entry->length,
}; };
int ready = 1; int ready = 1;
int original_flags = -1;
if (entry->drop_if_busy) { if (entry->drop_if_busy) {
fd_set write_set; fd_set write_set;
FD_ZERO(&write_set); FD_ZERO(&write_set);
@@ -209,6 +240,12 @@ static int drain_queue(maze32_gateway_t* gateway) {
struct timeval timeout = {0}; struct timeval timeout = {0};
ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 && ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 &&
FD_ISSET(entry->socket_fd, &write_set); FD_ISSET(entry->socket_fd, &write_set);
if (ready) {
original_flags = fcntl(entry->socket_fd, F_GETFL, 0);
if (original_flags >= 0) {
(void)fcntl(entry->socket_fd, F_SETFL, original_flags | O_NONBLOCK);
}
}
} }
if (!ready) { if (!ready) {
gateway->metrics.messages_dropped++; gateway->metrics.messages_dropped++;
@@ -229,6 +266,9 @@ static int drain_queue(maze32_gateway_t* gateway) {
close_slow_connection(gateway, entry->socket_fd); close_slow_connection(gateway, entry->socket_fd);
} }
} }
if (original_flags >= 0) {
(void)fcntl(entry->socket_fd, F_SETFL, original_flags);
}
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT; gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
gateway->outgoing_count--; gateway->outgoing_count--;
} }
@@ -561,7 +601,9 @@ static void countdown_finished_work(void* context) {
} }
} }
} }
drain_queue(gateway);
broadcast_snapshot(gateway); broadcast_snapshot(gateway);
drain_queue(gateway);
broadcast_views(gateway, 1); broadcast_views(gateway, 1);
drain_queue(gateway); drain_queue(gateway);
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms", ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
+2 -1
View File
@@ -1,5 +1,6 @@
#include "maze32_web.h" #include "maze32_web.h"
#include <fcntl.h>
#include <stdio.h> #include <stdio.h>
#include "esp_log.h" #include "esp_log.h"
@@ -107,7 +108,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
configuration.max_open_sockets = 9; configuration.max_open_sockets = 9;
configuration.lru_purge_enable = false; configuration.lru_purge_enable = false;
configuration.recv_wait_timeout = 5; configuration.recv_wait_timeout = 5;
configuration.send_wait_timeout = 1; configuration.send_wait_timeout = 5;
configuration.global_user_ctx = gateway; configuration.global_user_ctx = gateway;
configuration.global_user_ctx_free_fn = preserve_static_context; configuration.global_user_ctx_free_fn = preserve_static_context;
configuration.close_fn = maze32_gateway_close_socket; configuration.close_fn = maze32_gateway_close_socket;
+1 -1
View File
@@ -37,7 +37,7 @@ matches use the real 60-second reconnect deadline. It performs these checks:
2. Verify heartbeat round-trip latency for all six sessions. 2. Verify heartbeat round-trip latency for all six sessions.
3. Stop consuming one observer stream while Player 1 performs accepted moves; 3. Stop consuming one observer stream while Player 1 performs accepted moves;
intermediate observer updates may be dropped, but player acknowledgements intermediate observer updates may be dropped, but player acknowledgements
must remain within the reported 250 ms budget. must remain within the reported 500 ms budget.
4. Flood a second observer with malformed messages. The fixed rate limiter 4. Flood a second observer with malformed messages. The fixed rate limiter
closes sustained abuse, and the players remain responsive. closes sustained abuse, and the players remain responsive.
5. Complete and authoritatively reset two matches through the real disconnect 5. Complete and authoritatively reset two matches through the real disconnect
+1 -1
View File
@@ -12,7 +12,7 @@ compile-time bounds, not targets that may grow with match duration.
| Outgoing queue | 12 entries | Queue overflow is counted; memory cannot grow. | | Outgoing queue | 12 entries | Queue overflow is counted; memory cannot grow. |
| Game command queue | 16 entries | Excess commands receive `queue_full`. | | Game command queue | 16 entries | Excess commands receive `queue_full`. |
| Input rate | 20 messages/second | Excess messages are rejected; sustained abuse above 40 in the same window is disconnected. | | Input rate | 20 messages/second | Excess messages are rejected; sustained abuse above 40 in the same window is disconnected. |
| Player response budget | 250 ms | The physical stress client fails above this round-trip threshold. | | Player response budget | 500 ms | The physical stress client fails above this round-trip threshold. |
| Observer update policy | Latest bounded projection | A busy observer may lose intermediate dynamic chunks and recover on a newer projection. Failed or stalled required sends close the socket. | | Observer update policy | Latest bounded projection | A busy observer may lose intermediate dynamic chunks and recover on a newer projection. Failed or stalled required sends close the socket. |
| Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. | | Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. |
+48 -13
View File
@@ -178,7 +178,7 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
clients.extend((player_1, player_2)) clients.extend((player_1, player_2))
join(player_1, "player", "Нагрузочный Один") join(player_1, "player", "Нагрузочный Один")
session_2 = join(player_2, "player", "Нагрузочный Два") session_2 = join(player_2, "player", "Нагрузочный Два")
observers = [WebSocket(host, timeout=30.0) for _ in range(4)] observers = [WebSocket(host, timeout=120.0) for _ in range(4)]
clients.extend(observers) clients.extend(observers)
for observer in observers: for observer in observers:
join(observer, "observer") join(observer, "observer")
@@ -197,14 +197,35 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
for index in range(len(observers))] for index in range(len(observers))]
for reader in readers: for reader in readers:
reader.start() reader.start()
player_views: list[dict[str, Any] | None] = [None, None]
player_failures: list[BaseException] = []
def read_player(index: int, client: WebSocket) -> None:
try:
client.receive_type("active", attempts=50)
player_views[index] = receive_view(client)
except BaseException as error: # Preserve worker failure for the main thread.
player_failures.append(error)
player_readers = [
threading.Thread(target=read_player, args=(0, player_1)),
threading.Thread(target=read_player, args=(1, player_2)),
]
for reader in player_readers:
reader.start()
player_1.send("start", {}, "m010-start") player_1.send("start", {}, "m010-start")
player_1.receive_type("active", attempts=50) for reader in player_readers:
player_2.receive_type("active", attempts=50) reader.join(timeout=180.0)
player_1_view = receive_view(player_1)
receive_view(player_2)
for reader in readers: for reader in readers:
reader.join(timeout=40.0) reader.join(timeout=180.0)
assert not failures and all(not reader.is_alive() for reader in readers) assert not player_failures and all(not reader.is_alive() for reader in player_readers), (
f"player read failures: {player_failures!r}"
)
assert not failures and all(not reader.is_alive() for reader in readers), (
f"observer read failures: {failures!r}"
)
player_1_view = player_views[0]
assert player_1_view is not None and player_views[1] is not None
assert all(view is not None and len(view["cells"]) == 8192 assert all(view is not None and len(view["cells"]) == 8192
for view in observer_views) for view in observer_views)
@@ -248,6 +269,7 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
for observer in observers[1:]] for observer in observers[1:]]
for drainer in drainers: for drainer in drainers:
drainer.start() drainer.start()
opposite = {"north": "south", "east": "west", "south": "north", "west": "east"} opposite = {"north": "south", "east": "west", "south": "north", "west": "east"}
direction = open_direction(player_1_view) direction = open_direction(player_1_view)
command_latencies_ms: list[float] = [] command_latencies_ms: list[float] = []
@@ -264,7 +286,9 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
drain_stop.set() drain_stop.set()
for drainer in drainers: for drainer in drainers:
drainer.join(timeout=3.0) drainer.join(timeout=3.0)
assert not drain_failures and all(not drainer.is_alive() for drainer in drainers) assert not drain_failures and all(not drainer.is_alive() for drainer in drainers), (
f"observer drain failures: {drain_failures!r}"
)
for observer in observers[1:]: for observer in observers[1:]:
observer.socket.settimeout(30.0) observer.socket.settimeout(30.0)
@@ -290,7 +314,9 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
assert not flood.is_alive() assert not flood.is_alive()
stressed = diagnostics(host) stressed = diagnostics(host)
maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms) maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms)
assert maximum_latency_ms <= limits["playerLatencyBudgetMs"] assert maximum_latency_ms <= limits["playerLatencyBudgetMs"], (
f"maximum player latency {maximum_latency_ms:.1f} ms"
)
assert stressed["malformedMessages"] > baseline["malformedMessages"] assert stressed["malformedMessages"] > baseline["malformedMessages"]
assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"] assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"]
assert stressed["queueHighWater"] <= limits["queueEntries"] assert stressed["queueHighWater"] <= limits["queueEntries"]
@@ -329,11 +355,20 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
sample = diagnostics(host) sample = diagnostics(host)
reset_heap_samples.append(sample["internalFreeBytes"]) reset_heap_samples.append(sample["internalFreeBytes"])
if match_index + 1 < repeat_matches: if match_index + 1 < repeat_matches:
player_views = [None, None]
player_failures = []
player_readers = [
threading.Thread(target=read_player, args=(0, player_1)),
threading.Thread(target=read_player, args=(1, player_2)),
]
for reader in player_readers:
reader.start()
player_1.send("start", {}, f"m010-start-{match_index + 1}") player_1.send("start", {}, f"m010-start-{match_index + 1}")
player_1.receive_type("active", attempts=100) for reader in player_readers:
player_2.receive_type("active", attempts=100) reader.join(timeout=40.0)
receive_view(player_1) assert not player_failures and all(
receive_view(player_2) not reader.is_alive() for reader in player_readers
)
final_sample = diagnostics(host) final_sample = diagnostics(host)
assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches