fix(gateway): harden websocket streaming under client backpressure
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user