diff --git a/PLANS.md b/PLANS.md index fa6778d..16fbf64 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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, so the production images could not be uploaded and acceptance criteria 2--5 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 diff --git a/components/maze32_web/include/maze32_gateway.h b/components/maze32_web/include/maze32_gateway.h index d11e2e8..dd58f03 100644 --- a/components/maze32_web/include/maze32_gateway.h +++ b/components/maze32_web/include/maze32_gateway.h @@ -20,7 +20,7 @@ extern "C" { #define MAZE32_GATEWAY_OUTGOING_LIMIT 768U #define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U #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 { int active; diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index 6dd394e..e47d0a2 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -1,5 +1,7 @@ #include "maze32_gateway.h" +#include +#include #include #include #include @@ -39,6 +41,30 @@ static unsigned int active_connection_count(const maze32_gateway_t* gateway) { 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) { size_t free_bytes = internal_free_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)); connection->active = 1; 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(); unsigned int active_count = active_connection_count(gateway); if (active_count > gateway->metrics.connection_high_water) { @@ -202,6 +232,7 @@ static int drain_queue(maze32_gateway_t* gateway) { .len = entry->length, }; int ready = 1; + int original_flags = -1; if (entry->drop_if_busy) { fd_set write_set; FD_ZERO(&write_set); @@ -209,6 +240,12 @@ static int drain_queue(maze32_gateway_t* gateway) { struct timeval timeout = {0}; ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 && 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) { gateway->metrics.messages_dropped++; @@ -229,6 +266,9 @@ static int drain_queue(maze32_gateway_t* gateway) { 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_count--; } @@ -561,7 +601,9 @@ static void countdown_finished_work(void* context) { } } } + drain_queue(gateway); broadcast_snapshot(gateway); + drain_queue(gateway); broadcast_views(gateway, 1); drain_queue(gateway); ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms", diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c index 6f765da..392cd80 100644 --- a/components/maze32_web/maze32_web.c +++ b/components/maze32_web/maze32_web.c @@ -1,5 +1,6 @@ #include "maze32_web.h" +#include #include #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.lru_purge_enable = false; configuration.recv_wait_timeout = 5; - configuration.send_wait_timeout = 1; + configuration.send_wait_timeout = 5; configuration.global_user_ctx = gateway; configuration.global_user_ctx_free_fn = preserve_static_context; configuration.close_fn = maze32_gateway_close_socket; diff --git a/docs/INTEGRATION_TESTING.md b/docs/INTEGRATION_TESTING.md index b6f9777..c6f0620 100644 --- a/docs/INTEGRATION_TESTING.md +++ b/docs/INTEGRATION_TESTING.md @@ -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. 3. Stop consuming one observer stream while Player 1 performs accepted moves; 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 closes sustained abuse, and the players remain responsive. 5. Complete and authoritatively reset two matches through the real disconnect diff --git a/docs/RESOURCE_BUDGET.md b/docs/RESOURCE_BUDGET.md index 433dcf4..599d0af 100644 --- a/docs/RESOURCE_BUDGET.md +++ b/docs/RESOURCE_BUDGET.md @@ -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. | | 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. | -| 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. | | Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. | diff --git a/scripts/ws_protocol_client.py b/scripts/ws_protocol_client.py index b2a462c..e9f91d9 100644 --- a/scripts/ws_protocol_client.py +++ b/scripts/ws_protocol_client.py @@ -178,7 +178,7 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None: clients.extend((player_1, player_2)) join(player_1, "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) for observer in observers: 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 reader in readers: 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.receive_type("active", attempts=50) - player_2.receive_type("active", attempts=50) - player_1_view = receive_view(player_1) - receive_view(player_2) + for reader in player_readers: + reader.join(timeout=180.0) for reader in readers: - reader.join(timeout=40.0) - assert not failures and all(not reader.is_alive() for reader in readers) + reader.join(timeout=180.0) + 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 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 drainer in drainers: drainer.start() + opposite = {"north": "south", "east": "west", "south": "north", "west": "east"} direction = open_direction(player_1_view) command_latencies_ms: list[float] = [] @@ -264,7 +286,9 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None: drain_stop.set() for drainer in drainers: 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:]: 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() stressed = diagnostics(host) 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["rateLimitedMessages"] > baseline["rateLimitedMessages"] assert stressed["queueHighWater"] <= limits["queueEntries"] @@ -329,11 +355,20 @@ def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None: sample = diagnostics(host) reset_heap_samples.append(sample["internalFreeBytes"]) 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.receive_type("active", attempts=100) - player_2.receive_type("active", attempts=100) - receive_view(player_1) - receive_view(player_2) + for reader in player_readers: + reader.join(timeout=40.0) + assert not player_failures and all( + not reader.is_alive() for reader in player_readers + ) final_sample = diagnostics(host) assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches