feat(runtime): add integration hardening and diagnostics
This commit is contained in:
@@ -768,7 +768,7 @@ Implementation and required verification are complete.
|
|||||||
|
|
||||||
## Milestone 009 — Observer mode and disconnect recovery
|
## Milestone 009 — Observer mode and disconnect recovery
|
||||||
|
|
||||||
**Status:** `IN_PROGRESS`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -803,8 +803,7 @@ and reconnect behavior.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
Implementation is complete, but the milestone remains `IN_PROGRESS` until the
|
Implementation and required verification are complete.
|
||||||
required physical-device and observer-browser checks can be recorded.
|
|
||||||
|
|
||||||
- On 2026-09-05, `bash scripts/run_host_tests.sh` completed successfully. The
|
- On 2026-09-05, `bash scripts/run_host_tests.sh` completed successfully. The
|
||||||
new deterministic recovery suite proved immediate authoritative pause,
|
new deterministic recovery suite proved immediate authoritative pause,
|
||||||
@@ -834,22 +833,27 @@ required physical-device and observer-browser checks can be recorded.
|
|||||||
corresponding `buildfs` command completed with `[SUCCESS]` and reported
|
corresponding `buildfs` command completed with `[SUCCESS]` and reported
|
||||||
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`, with all five
|
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`, with all five
|
||||||
compressed assets present.
|
compressed assets present.
|
||||||
- Physical verification remains outstanding. The prepared dependency-free
|
- Automated live observer validation was initially unavailable because the
|
||||||
command `python3 scripts/ws_protocol_client.py 192.168.1.50 --verify-m009`
|
browser-control runtime returned `No browser is available` and its browser
|
||||||
covers simultaneous full-state delivery to four observers, observer mutation
|
inventory was empty. The project owner subsequently supplied the required
|
||||||
rejection, observer-disconnect continuity, same-state player restoration,
|
physical acceptance evidence recorded below.
|
||||||
and the real 60-second timeout draw. At the end of this pass,
|
- On 2026-09-05, the project owner completed all Milestone 009 physical checks
|
||||||
`/dev/ttyACM0` was absent and `192.168.1.50` was unreachable, so the final
|
and reported that the implementation worked as expected. The checks covered
|
||||||
`0.9.0` images could not be uploaded and this scenario could not be run.
|
simultaneous complete live-state delivery to four read-only observers,
|
||||||
- Automated live observer validation also remains outstanding. The
|
rejection and audit of observer mutation attempts, observer-disconnect match
|
||||||
browser-control runtime returned `No browser is available`, and its browser
|
continuity, immediate pause on either player disconnect, unchanged gameplay
|
||||||
inventory was empty. A live check at `1024 x 768` or larger plus a smaller
|
and effect state while paused, restoration of the same player state within
|
||||||
viewport must still confirm full-map fit, updates, zoom, pan, read-only UI,
|
60 seconds, and an authoritative draw after reconnect timeout. Manual browser
|
||||||
player connection state, and the required Russian large-screen notice.
|
checks also confirmed full-map fit, live player/object/connection updates,
|
||||||
|
zoom and pan at `1024 x 768` and larger, no observer mutation controls, and
|
||||||
|
the required Russian large-screen notice below that viewport. Together with
|
||||||
|
the deterministic, sanitizer, payload-bound, asset, and firmware evidence
|
||||||
|
above, this satisfies every Milestone 009 acceptance criterion. Milestone
|
||||||
|
009 is `COMPLETE`; Milestone 010 remains `NOT_STARTED`.
|
||||||
|
|
||||||
## Milestone 010 — On-device integration and resource hardening
|
## Milestone 010 — On-device integration and resource hardening
|
||||||
|
|
||||||
**Status:** `NOT_STARTED`
|
**Status:** `IN_PROGRESS`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -883,10 +887,43 @@ the ESP32-C6 Flash, RAM, socket, and network limits.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
Completion evidence is not yet available because this milestone has not
|
- `/home/sasa/.platformio/penv/bin/pio run` completed with `[SUCCESS]` on
|
||||||
started. Required evidence includes production build reports, physical-device
|
2026-09-05. It reported 124,276 of 327,680 RAM bytes and 1,028,600 of
|
||||||
logs, six-client load results, memory graphs or samples, and repeated-match
|
2,097,152 application bytes. `firmware.bin` measured 1,029,232 bytes in the
|
||||||
stress results.
|
2,097,152-byte factory partition, leaving 1,067,920 bytes. The ELF contains
|
||||||
|
application version `0.10.0`; the authoritative gateway's fixed static
|
||||||
|
allocation measured 86,840 bytes.
|
||||||
|
- `/home/sasa/.platformio/penv/bin/pio run -t buildfs` completed with
|
||||||
|
`[SUCCESS]`. The fixed LittleFS image measured 2,031,616 of 2,031,616
|
||||||
|
partition bytes, while the reproducibility check reported 19,253 of
|
||||||
|
1,572,864 allowed compressed asset bytes with SHA-256
|
||||||
|
`8f3207a208c26aa9f0faff0b0fd8db8e8f8e44537bd6db2c0f914ca5f352920a`.
|
||||||
|
- `bash scripts/run_host_tests.sh` passed the health, Wi-Fi configuration, NVS,
|
||||||
|
provisioning, lobby/protocol, 1,024-seed maze, game-core, mechanics,
|
||||||
|
disconnect-recovery, eight browser, and web-asset suites. The new portable
|
||||||
|
repeated-match test completed 128 reset/start/activate cycles while proving
|
||||||
|
that both player sessions remain byte-identical. A separate AddressSanitizer
|
||||||
|
and UndefinedBehaviorSanitizer run of the changed lobby/protocol suite passed
|
||||||
|
without findings.
|
||||||
|
- The production gateway now exposes internal-only heap/largest-block samples,
|
||||||
|
latency and traffic counters, connection and queue high-water marks, and
|
||||||
|
match-cycle counters at `GET /diagnostics`. It retains the fixed six-client,
|
||||||
|
512-byte input, 768-byte output, 12-entry output-queue, 16-entry game-command,
|
||||||
|
and 20-message-per-second bounds. Dynamic observer updates use a latest-view
|
||||||
|
drop policy under backpressure; failed required sends and sustained rate
|
||||||
|
abuse close the offending socket. No added code or test depends on PSRAM or
|
||||||
|
a GPIO.
|
||||||
|
- `python3 scripts/ws_protocol_client.py 192.168.1.50 --verify-m010
|
||||||
|
--stress-moves 80 --repeat-matches 2` is the documented target-hardware
|
||||||
|
procedure. It opens two players and four observers, measures latency and
|
||||||
|
internal heap, stresses a non-reading observer and malformed sender, and
|
||||||
|
completes and resets matches through the real 60-second reconnect deadline.
|
||||||
|
Station reconnect, fallback provisioning, and browser restoration steps are
|
||||||
|
recorded in `docs/INTEGRATION_TESTING.md` without requiring the development
|
||||||
|
host to leave Mazda7.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
## Milestone 011 — Gameplay tuning and MVP acceptance
|
## Milestone 011 — Gameplay tuning and MVP acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ $HOME/.platformio/penv/bin/pio device monitor
|
|||||||
A successful boot reports the firmware version, LittleFS capacity and use,
|
A successful boot reports the firmware version, LittleFS capacity and use,
|
||||||
network state, HTTP service startup, and health state `serving`.
|
network state, HTTP service startup, and health state `serving`.
|
||||||
|
|
||||||
|
The production integration/load procedure and its fixed resource thresholds
|
||||||
|
are documented in `docs/INTEGRATION_TESTING.md` and
|
||||||
|
`docs/RESOURCE_BUDGET.md`. Runtime counters and internal-memory samples are
|
||||||
|
available from `GET /diagnostics`; the endpoint contains no credentials,
|
||||||
|
session tokens, or game projection.
|
||||||
|
|
||||||
## Wi-Fi provisioning and recovery
|
## Wi-Fi provisioning and recovery
|
||||||
|
|
||||||
With no saved credentials, or after a failed station connection, the device
|
With no saved credentials, or after a failed station connection, the device
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection
|
|||||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
||||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
||||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
||||||
|
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
|
||||||
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
||||||
int connection_id);
|
int connection_id);
|
||||||
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby);
|
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ typedef enum {
|
|||||||
MAZE32_MESSAGE_START,
|
MAZE32_MESSAGE_START,
|
||||||
MAZE32_MESSAGE_MOVE,
|
MAZE32_MESSAGE_MOVE,
|
||||||
MAZE32_MESSAGE_DROP_KEY,
|
MAZE32_MESSAGE_DROP_KEY,
|
||||||
|
MAZE32_MESSAGE_RESTART,
|
||||||
MAZE32_MESSAGE_HEARTBEAT,
|
MAZE32_MESSAGE_HEARTBEAT,
|
||||||
MAZE32_MESSAGE_SUGGEST_NAME,
|
MAZE32_MESSAGE_SUGGEST_NAME,
|
||||||
} maze32_message_type_t;
|
} maze32_message_type_t;
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void maze32_lobby_reset_match(maze32_lobby_t* lobby) {
|
||||||
|
if (lobby == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lobby->phase = MAZE32_LOBBY_WAITING;
|
||||||
|
lobby->countdown_started_ms = 0U;
|
||||||
|
lobby->activation_ms = 0U;
|
||||||
|
lobby->revision++;
|
||||||
|
}
|
||||||
|
|
||||||
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
||||||
int connection_id) {
|
int connection_id) {
|
||||||
if (lobby == NULL) {
|
if (lobby == NULL) {
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ static maze32_message_type_t message_type(const char* value) {
|
|||||||
if (strcmp(value, "drop_key") == 0) {
|
if (strcmp(value, "drop_key") == 0) {
|
||||||
return MAZE32_MESSAGE_DROP_KEY;
|
return MAZE32_MESSAGE_DROP_KEY;
|
||||||
}
|
}
|
||||||
|
if (strcmp(value, "restart") == 0) {
|
||||||
|
return MAZE32_MESSAGE_RESTART;
|
||||||
|
}
|
||||||
if (strcmp(value, "heartbeat") == 0) {
|
if (strcmp(value, "heartbeat") == 0) {
|
||||||
return MAZE32_MESSAGE_HEARTBEAT;
|
return MAZE32_MESSAGE_HEARTBEAT;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ typedef struct {
|
|||||||
esp_netif_t* station_netif;
|
esp_netif_t* station_netif;
|
||||||
esp_netif_t* access_point_netif;
|
esp_netif_t* access_point_netif;
|
||||||
volatile maze32_network_state_t state;
|
volatile maze32_network_state_t state;
|
||||||
|
volatile int station_wait_active;
|
||||||
unsigned int retry_count;
|
unsigned int retry_count;
|
||||||
char access_point_ssid[24];
|
char access_point_ssid[24];
|
||||||
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
||||||
@@ -45,8 +46,7 @@ typedef struct {
|
|||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
} maze32_network_t;
|
} maze32_network_t;
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
|
||||||
void* guard_context);
|
void* guard_context);
|
||||||
esp_err_t maze32_network_start(maze32_network_t* network);
|
esp_err_t maze32_network_start(maze32_network_t* network);
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
@@ -54,15 +54,12 @@ esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
|||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t* network);
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network);
|
||||||
void maze32_network_retry_saved_credentials(void* context);
|
void maze32_network_retry_saved_credentials(void* context);
|
||||||
void maze32_network_return_to_provisioning(void* context);
|
void maze32_network_return_to_provisioning(void* context);
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
maze32_wifi_scan_result_t *results,
|
size_t capacity, size_t* count);
|
||||||
size_t capacity,
|
|
||||||
size_t *count);
|
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t* network);
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network);
|
||||||
const char* maze32_network_state_name(maze32_network_state_t state);
|
const char* maze32_network_state_name(maze32_network_state_t state);
|
||||||
const char* maze32_network_access_point_ssid(const maze32_network_t* network);
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network);
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
char *output,
|
|
||||||
size_t output_size);
|
size_t output_size);
|
||||||
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network);
|
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network);
|
||||||
|
|
||||||
|
|||||||
@@ -16,39 +16,58 @@
|
|||||||
static const char* TAG = "maze32_network";
|
static const char* TAG = "maze32_network";
|
||||||
static maze32_network_t* active_network;
|
static maze32_network_t* active_network;
|
||||||
|
|
||||||
static void set_state(maze32_network_t *network, maze32_network_state_t state)
|
static void set_state(maze32_network_t* network, maze32_network_state_t state) {
|
||||||
{
|
|
||||||
network->state = state;
|
network->state = state;
|
||||||
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void network_event(void *argument,
|
static void network_event(void* argument, esp_event_base_t event_base, int32_t event_id,
|
||||||
esp_event_base_t event_base,
|
void* event_data) {
|
||||||
int32_t event_id,
|
|
||||||
void *event_data)
|
|
||||||
{
|
|
||||||
(void)event_data;
|
(void)event_data;
|
||||||
maze32_network_t* network = argument;
|
maze32_network_t* network = argument;
|
||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
(network->state == MAZE32_NETWORK_STATION_CONNECTING ||
|
||||||
|
network->state == MAZE32_NETWORK_STATION_CONNECTED)) {
|
||||||
|
if (network->state == MAZE32_NETWORK_STATION_CONNECTED) {
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTING);
|
||||||
|
network->retry_count = 0U;
|
||||||
|
}
|
||||||
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
||||||
++network->retry_count;
|
++network->retry_count;
|
||||||
ESP_LOGW(TAG, "Station connection failed; retry %u/%u",
|
ESP_LOGW(TAG, "Station connection failed; retry %u/%u", network->retry_count,
|
||||||
network->retry_count, MAZE32_STATION_RETRY_LIMIT);
|
MAZE32_STATION_RETRY_LIMIT);
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else {
|
} else if (network->station_wait_active) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
||||||
|
} else if (network->server != NULL) {
|
||||||
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
|
esp_err_t result =
|
||||||
|
httpd_queue_work(network->server, maze32_network_return_to_provisioning, network);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not schedule runtime fallback: %s", esp_err_to_name(result));
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
}
|
}
|
||||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||||
|
if (network->station_wait_active) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
||||||
|
} else if (network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
|
network->retry_count = 0U;
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
|
char address[16];
|
||||||
|
if (maze32_network_ip_address(network, address, sizeof(address)) == ESP_OK) {
|
||||||
|
ESP_LOGI(TAG, "Runtime station reconnect succeeded: IP=%s", address);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t stop_wifi(void)
|
static esp_err_t stop_wifi(void) {
|
||||||
{
|
|
||||||
esp_err_t result = esp_wifi_stop();
|
esp_err_t result = esp_wifi_stop();
|
||||||
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
@@ -56,8 +75,7 @@ static esp_err_t stop_wifi(void)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t start_provisioning(maze32_network_t *network)
|
static esp_err_t start_provisioning(maze32_network_t* network) {
|
||||||
{
|
|
||||||
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
@@ -90,8 +108,7 @@ static esp_err_t start_provisioning(maze32_network_t *network)
|
|||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t connect_station(maze32_network_t* network,
|
static esp_err_t connect_station(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
@@ -113,19 +130,19 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
||||||
}
|
}
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
|
network->station_wait_active = 1;
|
||||||
result = esp_wifi_start();
|
result = esp_wifi_start();
|
||||||
}
|
}
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
|
network->station_wait_active = 0;
|
||||||
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBits_t bits = xEventGroupWaitBits(
|
EventBits_t bits =
|
||||||
network->events,
|
xEventGroupWaitBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, pdTRUE,
|
||||||
MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT,
|
pdFALSE, pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
||||||
pdTRUE,
|
network->station_wait_active = 0;
|
||||||
pdFALSE,
|
|
||||||
pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
|
||||||
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
||||||
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
char address[16];
|
char address[16];
|
||||||
@@ -139,22 +156,18 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int save_credentials(const maze32_wifi_credentials_t *credentials, void *context)
|
static int save_credentials(const maze32_wifi_credentials_t* credentials, void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int clear_credentials(void *context)
|
static int clear_credentials(void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
void* guard_context) {
|
||||||
void *guard_context)
|
|
||||||
{
|
|
||||||
if (network == NULL || guard == NULL || active_network != NULL) {
|
if (network == NULL || guard == NULL || active_network != NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -173,8 +186,8 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid),
|
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), "maze32-%02X%02X",
|
||||||
"maze32-%02X%02X", mac[4], mac[5]);
|
mac[4], mac[5]);
|
||||||
|
|
||||||
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
result = esp_wifi_init(&wifi_configuration);
|
result = esp_wifi_init(&wifi_configuration);
|
||||||
@@ -196,8 +209,7 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_start(maze32_network_t *network)
|
esp_err_t maze32_network_start(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -215,15 +227,12 @@ esp_err_t maze32_network_start(maze32_network_t *network)
|
|||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_apply(credentials,
|
int result = maze32_wifi_config_apply(credentials, network->reconfiguration_guard,
|
||||||
network->reconfiguration_guard,
|
save_credentials, network->guard_context);
|
||||||
save_credentials,
|
|
||||||
network->guard_context);
|
|
||||||
if (result == -1) {
|
if (result == -1) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -233,13 +242,11 @@ esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_reset(network->reconfiguration_guard,
|
int result = maze32_wifi_config_reset(network->reconfiguration_guard, clear_credentials,
|
||||||
clear_credentials,
|
|
||||||
network->guard_context);
|
network->guard_context);
|
||||||
if (result == -2) {
|
if (result == -2) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
@@ -247,8 +254,7 @@ esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_retry_saved_credentials(void *context)
|
void maze32_network_retry_saved_credentials(void* context) {
|
||||||
{
|
|
||||||
maze32_network_t* network = context;
|
maze32_network_t* network = context;
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
@@ -265,8 +271,7 @@ void maze32_network_retry_saved_credentials(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_return_to_provisioning(void *context)
|
void maze32_network_return_to_provisioning(void* context) {
|
||||||
{
|
|
||||||
maze32_network_t* network = context;
|
maze32_network_t* network = context;
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
@@ -278,11 +283,8 @@ void maze32_network_return_to_provisioning(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
maze32_wifi_scan_result_t *results,
|
size_t capacity, size_t* count) {
|
||||||
size_t capacity,
|
|
||||||
size_t *count)
|
|
||||||
{
|
|
||||||
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
||||||
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
||||||
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
||||||
@@ -313,34 +315,34 @@ esp_err_t maze32_network_scan(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t *network)
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_state_name(maze32_network_state_t state)
|
const char* maze32_network_state_name(maze32_network_state_t state) {
|
||||||
{
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAZE32_NETWORK_BOOTING: return "booting";
|
case MAZE32_NETWORK_BOOTING:
|
||||||
case MAZE32_NETWORK_STATION_CONNECTING: return "station_connecting";
|
return "booting";
|
||||||
case MAZE32_NETWORK_STATION_CONNECTED: return "station_connected";
|
case MAZE32_NETWORK_STATION_CONNECTING:
|
||||||
case MAZE32_NETWORK_PROVISIONING: return "provisioning";
|
return "station_connecting";
|
||||||
case MAZE32_NETWORK_ERROR: return "error";
|
case MAZE32_NETWORK_STATION_CONNECTED:
|
||||||
default: return "unknown";
|
return "station_connected";
|
||||||
|
case MAZE32_NETWORK_PROVISIONING:
|
||||||
|
return "provisioning";
|
||||||
|
case MAZE32_NETWORK_ERROR:
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_access_point_ssid(const maze32_network_t *network)
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? "" : network->access_point_ssid;
|
return network == NULL ? "" : network->access_point_ssid;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
char *output,
|
size_t output_size) {
|
||||||
size_t output_size)
|
if (network == NULL || output == NULL || output_size < 16U || network->station_netif == NULL) {
|
||||||
{
|
|
||||||
if (network == NULL || output == NULL || output_size < 16U ||
|
|
||||||
network->station_netif == NULL) {
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
esp_netif_ip_info_t information;
|
esp_netif_ip_info_t information;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ extern "C" {
|
|||||||
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
||||||
#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_PLAYER_LATENCY_BUDGET_MS 250U
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int active;
|
int active;
|
||||||
@@ -26,14 +28,36 @@ typedef struct {
|
|||||||
uint32_t last_client_sequence;
|
uint32_t last_client_sequence;
|
||||||
uint64_t rate_window_started_ms;
|
uint64_t rate_window_started_ms;
|
||||||
unsigned int messages_in_window;
|
unsigned int messages_in_window;
|
||||||
|
int closing;
|
||||||
} maze32_gateway_connection_t;
|
} maze32_gateway_connection_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int socket_fd;
|
int socket_fd;
|
||||||
size_t length;
|
size_t length;
|
||||||
|
int drop_if_busy;
|
||||||
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
} maze32_gateway_outgoing_t;
|
} maze32_gateway_outgoing_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t connection_high_water;
|
||||||
|
uint32_t queue_high_water;
|
||||||
|
uint32_t messages_received;
|
||||||
|
uint32_t messages_sent;
|
||||||
|
uint32_t messages_dropped;
|
||||||
|
uint32_t send_failures;
|
||||||
|
uint32_t malformed_messages;
|
||||||
|
uint32_t oversized_messages;
|
||||||
|
uint32_t rate_limited_messages;
|
||||||
|
uint32_t accepted_game_commands;
|
||||||
|
uint32_t matches_started;
|
||||||
|
uint32_t matches_completed;
|
||||||
|
uint32_t matches_reset;
|
||||||
|
uint64_t maximum_handler_us;
|
||||||
|
uint64_t maximum_send_us;
|
||||||
|
size_t minimum_internal_free_bytes;
|
||||||
|
size_t minimum_internal_largest_block_bytes;
|
||||||
|
} maze32_gateway_metrics_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
maze32_lobby_t lobby;
|
maze32_lobby_t lobby;
|
||||||
@@ -45,6 +69,8 @@ typedef struct {
|
|||||||
unsigned int suggestion_index;
|
unsigned int suggestion_index;
|
||||||
esp_timer_handle_t countdown_timer;
|
esp_timer_handle_t countdown_timer;
|
||||||
esp_timer_handle_t reconnect_timer;
|
esp_timer_handle_t reconnect_timer;
|
||||||
|
esp_timer_handle_t finish_timer;
|
||||||
|
maze32_gateway_metrics_t metrics;
|
||||||
maze32_maze_t maze;
|
maze32_maze_t maze;
|
||||||
maze32_maze_workspace_t maze_workspace;
|
maze32_maze_workspace_t maze_workspace;
|
||||||
maze32_game_t game;
|
maze32_game_t game;
|
||||||
@@ -56,6 +82,8 @@ void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server)
|
|||||||
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
||||||
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
||||||
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
||||||
|
size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output,
|
||||||
|
size_t output_size);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <inttypes.h>
|
#include <inttypes.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/select.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
@@ -20,6 +21,46 @@ static uint64_t now_ms(void) {
|
|||||||
return (uint64_t)(esp_timer_get_time() / 1000);
|
return (uint64_t)(esp_timer_get_time() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static size_t internal_free_bytes(void) {
|
||||||
|
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t internal_largest_block_bytes(void) {
|
||||||
|
return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned int active_connection_count(const maze32_gateway_t* gateway) {
|
||||||
|
unsigned int count = 0U;
|
||||||
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
|
if (gateway->connections[index].active) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (gateway->metrics.minimum_internal_free_bytes == 0U ||
|
||||||
|
free_bytes < gateway->metrics.minimum_internal_free_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_free_bytes = free_bytes;
|
||||||
|
}
|
||||||
|
if (gateway->metrics.minimum_internal_largest_block_bytes == 0U ||
|
||||||
|
largest_block < gateway->metrics.minimum_internal_largest_block_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_largest_block_bytes = largest_block;
|
||||||
|
}
|
||||||
|
if (label != NULL) {
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M010 resources: label=%s active=%u queue=%u free=%u largest=%u min_free=%u "
|
||||||
|
"min_largest=%u",
|
||||||
|
label, active_connection_count(gateway), (unsigned int)gateway->outgoing_count,
|
||||||
|
(unsigned int)free_bytes, (unsigned int)largest_block,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_free_bytes,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_largest_block_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
if (gateway->connections[index].active &&
|
if (gateway->connections[index].active &&
|
||||||
@@ -42,19 +83,14 @@ static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, i
|
|||||||
connection->active = 1;
|
connection->active = 1;
|
||||||
connection->socket_fd = socket_fd;
|
connection->socket_fd = socket_fd;
|
||||||
connection->rate_window_started_ms = now_ms();
|
connection->rate_window_started_ms = now_ms();
|
||||||
unsigned int active_count = 0U;
|
unsigned int active_count = active_connection_count(gateway);
|
||||||
for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT;
|
if (active_count > gateway->metrics.connection_high_water) {
|
||||||
++count_index) {
|
gateway->metrics.connection_high_water = active_count;
|
||||||
if (gateway->connections[count_index].active) {
|
|
||||||
active_count++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
||||||
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
||||||
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
||||||
ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes",
|
sample_resources(gateway, "six_clients");
|
||||||
(unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT),
|
|
||||||
(unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
|
|
||||||
}
|
}
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -112,10 +148,16 @@ static uint32_t next_server_sequence(maze32_gateway_t* gateway) {
|
|||||||
return gateway->server_sequence;
|
return gateway->server_sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
static int enqueue_with_policy(maze32_gateway_t* gateway, int socket_fd, const char* payload,
|
||||||
|
int drop_if_busy) {
|
||||||
size_t length = strlen(payload);
|
size_t length = strlen(payload);
|
||||||
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
||||||
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
||||||
|
if (drop_if_busy) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
}
|
||||||
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -123,12 +165,33 @@ static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload
|
|||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
||||||
entry->socket_fd = socket_fd;
|
entry->socket_fd = socket_fd;
|
||||||
entry->length = length;
|
entry->length = length;
|
||||||
|
entry->drop_if_busy = drop_if_busy;
|
||||||
memcpy(entry->payload, payload, length + 1U);
|
memcpy(entry->payload, payload, length + 1U);
|
||||||
gateway->outgoing_count++;
|
gateway->outgoing_count++;
|
||||||
|
if (gateway->outgoing_count > gateway->metrics.queue_high_water) {
|
||||||
|
gateway->metrics.queue_high_water = (uint32_t)gateway->outgoing_count;
|
||||||
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void drain_queue(maze32_gateway_t* gateway) {
|
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
||||||
|
return enqueue_with_policy(gateway, socket_fd, payload, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void close_slow_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
|
if (connection == NULL || connection->closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection->closing = 1;
|
||||||
|
ESP_LOGW(TAG, "Closing an unresponsive WebSocket: fd=%d", socket_fd);
|
||||||
|
if (httpd_sess_trigger_close(gateway->server, socket_fd) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Could not schedule close for fd=%d", socket_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int drain_queue(maze32_gateway_t* gateway) {
|
||||||
|
int success = 1;
|
||||||
while (gateway->outgoing_count > 0U) {
|
while (gateway->outgoing_count > 0U) {
|
||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
||||||
httpd_ws_frame_t frame = {
|
httpd_ws_frame_t frame = {
|
||||||
@@ -138,14 +201,38 @@ static void drain_queue(maze32_gateway_t* gateway) {
|
|||||||
.payload = (uint8_t*)entry->payload,
|
.payload = (uint8_t*)entry->payload,
|
||||||
.len = entry->length,
|
.len = entry->length,
|
||||||
};
|
};
|
||||||
|
int ready = 1;
|
||||||
|
if (entry->drop_if_busy) {
|
||||||
|
fd_set write_set;
|
||||||
|
FD_ZERO(&write_set);
|
||||||
|
FD_SET(entry->socket_fd, &write_set);
|
||||||
|
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) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
||||||
if (result != ESP_OK) {
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
if (elapsed_us > gateway->metrics.maximum_send_us) {
|
||||||
|
gateway->metrics.maximum_send_us = elapsed_us;
|
||||||
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
gateway->metrics.messages_sent++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
success = 0;
|
||||||
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
||||||
esp_err_to_name(result));
|
esp_err_to_name(result));
|
||||||
|
close_slow_connection(gateway, entry->socket_fd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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--;
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
|
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
|
||||||
@@ -176,10 +263,13 @@ static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(void)written;
|
(void)written;
|
||||||
if (!enqueue(gateway, session->connection_id, payload)) {
|
int drop_if_busy = session->role == MAZE32_ROLE_OBSERVER && !full_observer_view;
|
||||||
|
if (!enqueue_with_policy(gateway, session->connection_id, payload, drop_if_busy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drain_queue(gateway)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
drain_queue(gateway);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,19 +579,82 @@ static void countdown_timer_callback(void* context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
|
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway);
|
||||||
|
|
||||||
|
static const char* final_result_name(maze32_game_final_result_t result) {
|
||||||
|
switch (result) {
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_ONE:
|
||||||
|
return "player1";
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_TWO:
|
||||||
|
return "player2";
|
||||||
|
case MAZE32_GAME_FINAL_DRAW:
|
||||||
|
return "draw";
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void complete_match(maze32_gateway_t* gateway) {
|
||||||
|
char payload[48];
|
||||||
|
int written = snprintf(payload, sizeof(payload), "{\"result\":\"%s\"}",
|
||||||
|
final_result_name(gateway->game.final_result));
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||||
|
broadcast_match_event(gateway, "match_result", payload);
|
||||||
|
}
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
drain_queue(gateway);
|
||||||
|
gateway->metrics.matches_completed++;
|
||||||
|
sample_resources(gateway, "match_complete");
|
||||||
|
}
|
||||||
|
|
||||||
static void reconnect_deadline_work(void* context) {
|
static void reconnect_deadline_work(void* context) {
|
||||||
maze32_gateway_t* gateway = context;
|
maze32_gateway_t* gateway = context;
|
||||||
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
broadcast_match_event(gateway, "match_result", "{\"result\":\"draw\"}");
|
complete_match(gateway);
|
||||||
broadcast_views(gateway, 0);
|
|
||||||
drain_queue(gateway);
|
|
||||||
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
|
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
schedule_reconnect_deadline(gateway);
|
schedule_reconnect_deadline(gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void finish_deadline_work(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
|
complete_match(gateway);
|
||||||
|
ESP_LOGI(TAG, "Authoritative finish window closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_timer_callback(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (gateway->server != NULL) {
|
||||||
|
esp_err_t result = httpd_queue_work(gateway->server, finish_deadline_work, gateway);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not queue finish deadline: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway) {
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||||
|
gateway->game.first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t exit_ms = gateway->game.exit_times_ms[gateway->game.first_exited_player];
|
||||||
|
uint64_t window_ms = MAZE32_GAME_DRAW_WINDOW_MS + 1U;
|
||||||
|
uint64_t deadline = exit_ms > UINT64_MAX - window_ms ? UINT64_MAX : exit_ms + window_ms;
|
||||||
|
uint64_t current_ms = now_ms();
|
||||||
|
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||||
|
esp_err_t result = esp_timer_start_once(gateway->finish_timer, delay_ms * 1000U);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Finish timer could not start: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void reconnect_timer_callback(void* context) {
|
static void reconnect_timer_callback(void* context) {
|
||||||
maze32_gateway_t* gateway = context;
|
maze32_gateway_t* gateway = context;
|
||||||
if (gateway->server != NULL) {
|
if (gateway->server != NULL) {
|
||||||
@@ -597,11 +750,16 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
|
|||||||
? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction),
|
? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction),
|
||||||
now_ms(), &command_sequence)
|
now_ms(), &command_sequence)
|
||||||
: maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence);
|
: maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence);
|
||||||
|
maze32_game_command_result_t processed = {.result = result};
|
||||||
if (result == MAZE32_GAME_RESULT_ACCEPTED) {
|
if (result == MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
maze32_game_command_result_t processed = maze32_game_process_next(&gateway->game);
|
processed = maze32_game_process_next(&gateway->game);
|
||||||
result = processed.result;
|
result = processed.result;
|
||||||
}
|
}
|
||||||
|
int finished_now = processed.finished && gateway->game.phase == MAZE32_GAME_PHASE_FINISHED;
|
||||||
if (result != MAZE32_GAME_RESULT_ACCEPTED) {
|
if (result != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
if (finished_now) {
|
||||||
|
complete_match(gateway);
|
||||||
|
}
|
||||||
send_game_error(gateway, session->connection_id, message->request_id, result);
|
send_game_error(gateway, session->connection_id, message->request_id, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -622,9 +780,85 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
|
|||||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
||||||
enqueue(gateway, session->connection_id, acknowledgement);
|
enqueue(gateway, session->connection_id, acknowledgement);
|
||||||
}
|
}
|
||||||
|
gateway->metrics.accepted_game_commands++;
|
||||||
|
if (finished_now) {
|
||||||
|
complete_match(gateway);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
broadcast_views(gateway, 0);
|
broadcast_views(gateway, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t initialize_game(maze32_gateway_t* gateway) {
|
||||||
|
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||||
|
maze32_maze_report_t maze_report;
|
||||||
|
if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(),
|
||||||
|
&maze_report) != MAZE32_MAZE_OK) {
|
||||||
|
ESP_LOGE(TAG, "Maze generation failed: %s", maze32_maze_result_name(maze_report.result));
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
return maze32_game_init(&gateway->game, &gateway->maze, &game_config) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED
|
||||||
|
? ESP_OK
|
||||||
|
: ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_restart(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
|
const maze32_client_message_t* message) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=restart", session->connection_id);
|
||||||
|
send_game_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_GAME_RESULT_READ_ONLY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session->player_slot != 1U) {
|
||||||
|
send_lobby_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_LOBBY_NOT_OWNER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_FINISHED) {
|
||||||
|
send_error(gateway, session->connection_id, message->request_id, "invalid_phase",
|
||||||
|
"Новая гонка доступна после финиша текущей");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!maze32_lobby_ready(&gateway->lobby)) {
|
||||||
|
send_lobby_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_LOBBY_NOT_READY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialize_game(gateway) != ESP_OK) {
|
||||||
|
send_error(gateway, session->connection_id, message->request_id, "maze_generation_failed",
|
||||||
|
"Лабиринт отказался пересаживаться. Попробуйте ещё раз");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
esp_timer_stop(gateway->countdown_timer);
|
||||||
|
esp_timer_stop(gateway->reconnect_timer);
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
maze32_lobby_reset_match(&gateway->lobby);
|
||||||
|
gateway->metrics.matches_reset++;
|
||||||
|
char acknowledgement[192] = "";
|
||||||
|
char prefix[96];
|
||||||
|
size_t length = 0U;
|
||||||
|
int written = snprintf(prefix, sizeof(prefix),
|
||||||
|
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||||
|
next_server_sequence(gateway));
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||||
|
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
|
message->request_id) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
|
",\"payload\":{\"command\":\"restart\"}}")) {
|
||||||
|
enqueue(gateway, session->connection_id, acknowledgement);
|
||||||
|
}
|
||||||
|
broadcast_match_event(gateway, "reset", "{}");
|
||||||
|
broadcast_snapshot(gateway);
|
||||||
|
drain_queue(gateway);
|
||||||
|
sample_resources(gateway, "match_reset");
|
||||||
|
}
|
||||||
|
|
||||||
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
|
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
|
||||||
const maze32_client_message_t* message) {
|
const maze32_client_message_t* message) {
|
||||||
int socket_fd = connection->socket_fd;
|
int socket_fd = connection->socket_fd;
|
||||||
@@ -682,6 +916,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
broadcast_match_event(gateway, "resume", "{}");
|
broadcast_match_event(gateway, "resume", "{}");
|
||||||
ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot);
|
ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot);
|
||||||
}
|
}
|
||||||
|
if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
@@ -711,6 +948,8 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
send_lobby_error(gateway, socket_fd, message->request_id, result);
|
send_lobby_error(gateway, socket_fd, message->request_id, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
gateway->metrics.matches_started++;
|
||||||
|
sample_resources(gateway, "match_start");
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
broadcast_countdown(gateway);
|
broadcast_countdown(gateway);
|
||||||
esp_err_t timer_result =
|
esp_err_t timer_result =
|
||||||
@@ -724,6 +963,10 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
handle_game_command(gateway, session, message);
|
handle_game_command(gateway, session, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message->type == MAZE32_MESSAGE_RESTART) {
|
||||||
|
handle_restart(gateway, session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||||
size_t length = 0U;
|
size_t length = 0U;
|
||||||
@@ -741,7 +984,7 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t websocket_handler(httpd_req_t* request) {
|
static esp_err_t websocket_handler_impl(httpd_req_t* request) {
|
||||||
maze32_gateway_t* gateway = request->user_ctx;
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
int socket_fd = httpd_req_to_sockfd(request);
|
int socket_fd = httpd_req_to_sockfd(request);
|
||||||
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
@@ -758,7 +1001,9 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
gateway->metrics.messages_received++;
|
||||||
if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
|
if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
|
||||||
|
gateway->metrics.oversized_messages++;
|
||||||
ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd,
|
ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd,
|
||||||
(unsigned int)frame.len);
|
(unsigned int)frame.len);
|
||||||
shutdown(socket_fd, SHUT_RDWR);
|
shutdown(socket_fd, SHUT_RDWR);
|
||||||
@@ -780,8 +1025,13 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
}
|
}
|
||||||
payload[frame.len] = '\0';
|
payload[frame.len] = '\0';
|
||||||
if (!rate_limit_allows(connection, now_ms())) {
|
if (!rate_limit_allows(connection, now_ms())) {
|
||||||
|
gateway->metrics.rate_limited_messages++;
|
||||||
send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд");
|
send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд");
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
if (connection->messages_in_window > MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD) {
|
||||||
|
ESP_LOGW(TAG, "Sustained rate-limit abuse; closing fd=%d", socket_fd);
|
||||||
|
close_slow_connection(gateway, socket_fd);
|
||||||
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +1039,7 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
maze32_protocol_result_t parse_result =
|
maze32_protocol_result_t parse_result =
|
||||||
maze32_protocol_parse((const char*)payload, frame.len, &message);
|
maze32_protocol_parse((const char*)payload, frame.len, &message);
|
||||||
if (parse_result != MAZE32_PROTOCOL_OK) {
|
if (parse_result != MAZE32_PROTOCOL_OK) {
|
||||||
|
gateway->metrics.malformed_messages++;
|
||||||
send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result),
|
send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result),
|
||||||
"Пакет не прошёл проверку протокола");
|
"Пакет не прошёл проверку протокола");
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
@@ -806,6 +1057,20 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t websocket_handler(httpd_req_t* request) {
|
||||||
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
esp_err_t result = websocket_handler_impl(request);
|
||||||
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
if (gateway != NULL) {
|
||||||
|
if (elapsed_us > gateway->metrics.maximum_handler_us) {
|
||||||
|
gateway->metrics.maximum_handler_us = elapsed_us;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
static esp_err_t websocket_pre_handshake(httpd_req_t* request) {
|
static esp_err_t websocket_pre_handshake(httpd_req_t* request) {
|
||||||
maze32_gateway_t* gateway = request->user_ctx;
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
int socket_fd = httpd_req_to_sockfd(request);
|
int socket_fd = httpd_req_to_sockfd(request);
|
||||||
@@ -822,17 +1087,7 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
|||||||
}
|
}
|
||||||
memset(gateway, 0, sizeof(*gateway));
|
memset(gateway, 0, sizeof(*gateway));
|
||||||
maze32_lobby_init(&gateway->lobby);
|
maze32_lobby_init(&gateway->lobby);
|
||||||
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
if (initialize_game(gateway) != ESP_OK) {
|
||||||
maze32_maze_report_t maze_report;
|
|
||||||
if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(),
|
|
||||||
&maze_report) != MAZE32_MAZE_OK) {
|
|
||||||
ESP_LOGE(TAG, "Initial maze generation failed: %s",
|
|
||||||
maze32_maze_result_name(maze_report.result));
|
|
||||||
return ESP_FAIL;
|
|
||||||
}
|
|
||||||
maze32_game_config_t game_config = maze32_game_default_config();
|
|
||||||
if (maze32_game_init(&gateway->game, &gateway->maze, &game_config) !=
|
|
||||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
const esp_timer_create_args_t timer_arguments = {
|
const esp_timer_create_args_t timer_arguments = {
|
||||||
@@ -856,7 +1111,22 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
|||||||
result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer);
|
result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer);
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
esp_timer_delete(gateway->countdown_timer);
|
esp_timer_delete(gateway->countdown_timer);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
const esp_timer_create_args_t finish_timer_arguments = {
|
||||||
|
.callback = finish_timer_callback,
|
||||||
|
.arg = gateway,
|
||||||
|
.dispatch_method = ESP_TIMER_TASK,
|
||||||
|
.name = "finish_deadline",
|
||||||
|
.skip_unhandled_events = true,
|
||||||
|
};
|
||||||
|
result = esp_timer_create(&finish_timer_arguments, &gateway->finish_timer);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
esp_timer_delete(gateway->reconnect_timer);
|
||||||
|
esp_timer_delete(gateway->countdown_timer);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, "startup");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,6 +1182,9 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
|||||||
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||||
broadcast_match_event(gateway, "pause", payload);
|
broadcast_match_event(gateway, "pause", payload);
|
||||||
}
|
}
|
||||||
|
if (gateway->game.phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
}
|
||||||
schedule_reconnect_deadline(gateway);
|
schedule_reconnect_deadline(gateway);
|
||||||
ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms",
|
ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms",
|
||||||
disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS);
|
disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS);
|
||||||
@@ -924,6 +1197,7 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
|||||||
broadcast_views(gateway, 0);
|
broadcast_views(gateway, 0);
|
||||||
}
|
}
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
|
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,3 +1207,42 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
|||||||
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) {
|
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) {
|
||||||
return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby);
|
return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output,
|
||||||
|
size_t output_size) {
|
||||||
|
if (gateway == NULL || output == NULL || output_size == 0U) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
|
int written = snprintf(
|
||||||
|
output, output_size,
|
||||||
|
"{\"activeConnections\":%u,\"connectionHighWater\":%" PRIu32
|
||||||
|
",\"queueDepth\":%u,\"queueHighWater\":%" PRIu32 ",\"messagesReceived\":%" PRIu32
|
||||||
|
",\"messagesSent\":%" PRIu32 ",\"messagesDropped\":%" PRIu32 ",\"sendFailures\":%" PRIu32
|
||||||
|
",\"malformedMessages\":%" PRIu32 ",\"oversizedMessages\":%" PRIu32
|
||||||
|
",\"rateLimitedMessages\":%" PRIu32 ",\"acceptedGameCommands\":%" PRIu32
|
||||||
|
",\"matchesStarted\":%" PRIu32 ",\"matchesCompleted\":%" PRIu32 ",\"matchesReset\":%" PRIu32
|
||||||
|
",\"maximumHandlerUs\":%" PRIu64 ",\"maximumSendUs\":%" PRIu64
|
||||||
|
",\"internalFreeBytes\":%u,\"internalLargestBlockBytes\":%u,"
|
||||||
|
"\"minimumInternalFreeBytes\":%u,\"minimumInternalLargestBlockBytes\":%u,"
|
||||||
|
"\"limits\":{\"connections\":%u,\"messageBytes\":%u,\"outgoingBytes\":%u,"
|
||||||
|
"\"queueEntries\":%u,\"gameCommands\":%u,\"lwipSockets\":%u,"
|
||||||
|
"\"playerLatencyBudgetMs\":%u,\"psramRequired\":false}}",
|
||||||
|
active_connection_count(gateway), gateway->metrics.connection_high_water,
|
||||||
|
(unsigned int)gateway->outgoing_count, gateway->metrics.queue_high_water,
|
||||||
|
gateway->metrics.messages_received, gateway->metrics.messages_sent,
|
||||||
|
gateway->metrics.messages_dropped, gateway->metrics.send_failures,
|
||||||
|
gateway->metrics.malformed_messages, gateway->metrics.oversized_messages,
|
||||||
|
gateway->metrics.rate_limited_messages, gateway->metrics.accepted_game_commands,
|
||||||
|
gateway->metrics.matches_started, gateway->metrics.matches_completed,
|
||||||
|
gateway->metrics.matches_reset, gateway->metrics.maximum_handler_us,
|
||||||
|
gateway->metrics.maximum_send_us, (unsigned int)internal_free_bytes(),
|
||||||
|
(unsigned int)internal_largest_block_bytes(),
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_free_bytes,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_largest_block_bytes,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT, (unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_OUTGOING_LIMIT, (unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT,
|
||||||
|
(unsigned int)MAZE32_GAME_COMMAND_CAPACITY, (unsigned int)CONFIG_LWIP_MAX_SOCKETS,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS);
|
||||||
|
return written < 0 || (size_t)written >= output_size ? 0U : (size_t)written;
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ static esp_err_t send_health(httpd_req_t* request) {
|
|||||||
return httpd_resp_send(request, response, length);
|
return httpd_resp_send(request, response, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t send_diagnostics(httpd_req_t* request) {
|
||||||
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
|
char response[1024];
|
||||||
|
size_t length = maze32_gateway_format_diagnostics(gateway, response, sizeof(response));
|
||||||
|
if (length == 0U) {
|
||||||
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
|
"Диагностика временно недоступна");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
httpd_resp_set_type(request, "application/json");
|
||||||
|
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||||
|
return httpd_resp_send(request, response, (ssize_t)length);
|
||||||
|
}
|
||||||
|
|
||||||
esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
||||||
maze32_gateway_t* gateway) {
|
maze32_gateway_t* gateway) {
|
||||||
if (server == NULL || health == NULL || gateway == NULL) {
|
if (server == NULL || health == NULL || gateway == NULL) {
|
||||||
@@ -89,7 +103,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
}
|
}
|
||||||
|
|
||||||
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||||
configuration.max_uri_handlers = 12;
|
configuration.max_uri_handlers = 13;
|
||||||
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;
|
||||||
@@ -139,6 +153,12 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
.handler = send_health,
|
.handler = send_health,
|
||||||
.user_ctx = health,
|
.user_ctx = health,
|
||||||
};
|
};
|
||||||
|
const httpd_uri_t diagnostics_uri = {
|
||||||
|
.uri = "/diagnostics",
|
||||||
|
.method = HTTP_GET,
|
||||||
|
.handler = send_diagnostics,
|
||||||
|
.user_ctx = gateway,
|
||||||
|
};
|
||||||
|
|
||||||
result = httpd_register_uri_handler(*server, &root);
|
result = httpd_register_uri_handler(*server, &root);
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
@@ -156,6 +176,9 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &health_uri);
|
result = httpd_register_uri_handler(*server, &health_uri);
|
||||||
}
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &diagnostics_uri);
|
||||||
|
}
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
httpd_stop(*server);
|
httpd_stop(*server);
|
||||||
*server = NULL;
|
*server = NULL;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# On-device integration testing
|
||||||
|
|
||||||
|
This procedure verifies Milestone 010 on the ESP32-C6FH4 without PSRAM or any
|
||||||
|
assumed GPIO. Run it against production firmware and the production LittleFS
|
||||||
|
image. Start from a freshly booted device with no occupied lobby sessions.
|
||||||
|
|
||||||
|
## Build and delivery
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target uploadfs --upload-port /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor the USB serial log at 115200 baud. Confirm application version
|
||||||
|
`0.10.0`, the 4 MB partition table, successful LittleFS mount, station or
|
||||||
|
fallback network startup, registration of all HTTP/WebSocket routes, and
|
||||||
|
health state `serving`.
|
||||||
|
|
||||||
|
## Resource and load scenario
|
||||||
|
|
||||||
|
Keep the development host connected to `Mazda7`; do not switch it to the
|
||||||
|
device fallback access point. When the board has station address
|
||||||
|
`192.168.1.50`, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/ws_protocol_client.py 192.168.1.50 \
|
||||||
|
--verify-m010 --stress-moves 80 --repeat-matches 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The scenario intentionally takes more than two minutes because both repeated
|
||||||
|
matches use the real 60-second reconnect deadline. It performs these checks:
|
||||||
|
|
||||||
|
1. Connect two players and four observers and assemble all four complete
|
||||||
|
8,192-cell observer projections.
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
timeout. Each reset generates a new server-owned maze while preserving the
|
||||||
|
connected sessions.
|
||||||
|
6. Fetch `/diagnostics` before, during, and after the run. Assert the connection
|
||||||
|
and queue high-water marks remain within six and twelve, internal free heap
|
||||||
|
stays above 32 KiB, the largest internal block stays above 16 KiB, and the
|
||||||
|
post-reset free-heap samples remain within a 32 KiB range.
|
||||||
|
|
||||||
|
Capture the command output, the final diagnostics JSON, and serial lines that
|
||||||
|
begin with `M010 resources:`. The JSON counters are monotonic since boot and
|
||||||
|
include connections, queue use, dropped messages, failures, malformed/rate-
|
||||||
|
limited input, commands, match cycles, maximum handler/send time, and current
|
||||||
|
and minimum internal heap samples.
|
||||||
|
|
||||||
|
## Station, fallback, and browser recovery
|
||||||
|
|
||||||
|
These checks involve network state and must be performed from a separate phone
|
||||||
|
or computer so the development host retains its Mazda7 internet connection.
|
||||||
|
|
||||||
|
1. Reboot with the saved Mazda7 configuration and confirm
|
||||||
|
`/api/wifi/status` reports `station_connected`. Reload both player pages
|
||||||
|
during an active match and verify their tokens restore the same slots and
|
||||||
|
authoritative state.
|
||||||
|
2. From the separate client, exercise the established provisioning procedure
|
||||||
|
with an intentionally unavailable test network. Confirm the bounded station
|
||||||
|
attempts return the device to `maze32-XXXX` at
|
||||||
|
`http://192.168.4.1/provision` and that the Russian page remains usable.
|
||||||
|
3. Restore Mazda7 through the provisioning page without recording its
|
||||||
|
password. Reboot and confirm station connection, browser recovery, `/health`,
|
||||||
|
and `/diagnostics` again.
|
||||||
|
|
||||||
|
Do not record an acceptance result unless the physical logs and samples are
|
||||||
|
available. Do not use an LED, a board pin, or a PSRAM capability as a test
|
||||||
|
oracle.
|
||||||
@@ -37,6 +37,9 @@ is only echoed for correlation; it grants no authority.
|
|||||||
unpaused match may submit movement intent.
|
unpaused match may submit movement intent.
|
||||||
- `drop_key`: `{}`. Only a player in an active, unpaused match may submit the
|
- `drop_key`: `{}`. Only a player in an active, unpaused match may submit the
|
||||||
key-drop intent.
|
key-drop intent.
|
||||||
|
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
|
||||||
|
players must be connected. Sessions are preserved while a new authoritative
|
||||||
|
maze and game state are created.
|
||||||
- `heartbeat`: `{}` after a session has been joined or restored.
|
- `heartbeat`: `{}` after a session has been joined or restored.
|
||||||
|
|
||||||
## Server messages
|
## Server messages
|
||||||
@@ -57,6 +60,8 @@ is only echoed for correlation; it grants no authority.
|
|||||||
- `resume`: reports that both players are attached and authoritative gameplay
|
- `resume`: reports that both players are attached and authoritative gameplay
|
||||||
has resumed.
|
has resumed.
|
||||||
- `match_result`: reports the terminal winner or draw.
|
- `match_result`: reports the terminal winner or draw.
|
||||||
|
- `reset`: reports the authoritative return from a finished match to the
|
||||||
|
waiting lobby.
|
||||||
- `ack`: successful heartbeat correlation.
|
- `ack`: successful heartbeat correlation.
|
||||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||||
|
|
||||||
@@ -67,3 +72,13 @@ so a refresh can restore it. A player disconnect pauses the game for at most 60
|
|||||||
seconds; restoration reattaches the same state, while expiry produces a draw.
|
seconds; restoration reattaches the same state, while expiry produces a draw.
|
||||||
A disconnected observer token may be restored until its bounded slot is reused
|
A disconnected observer token may be restored until its bounded slot is reused
|
||||||
by a new observer, and observer disconnects never pause the match.
|
by a new observer, and observer disconnects never pause the match.
|
||||||
|
|
||||||
|
## Runtime diagnostics
|
||||||
|
|
||||||
|
`GET /diagnostics` returns bounded operational counters and internal-memory
|
||||||
|
samples without exposing session tokens, Wi-Fi credentials, or hidden maze
|
||||||
|
state. Counters cover connection and queue high-water marks, sent/dropped/
|
||||||
|
failed messages, malformed and rate-limited input, accepted game commands,
|
||||||
|
match cycles, maximum WebSocket handler/send duration, and current/minimum
|
||||||
|
internal free heap and largest block. The response also publishes the fixed
|
||||||
|
limits used by the Milestone 010 physical stress client.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Runtime resource budget
|
||||||
|
|
||||||
|
Milestone 010 fixes and exposes the following production limits. They are
|
||||||
|
compile-time bounds, not targets that may grow with match duration.
|
||||||
|
|
||||||
|
| Resource | Limit | Defensive behavior |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| WebSocket connections | 6 | A seventh WebSocket handshake is rejected. |
|
||||||
|
| LwIP sockets | 16 | ESP-IDF remains configured by `sdkconfig.defaults`. |
|
||||||
|
| Client message | 512 bytes | An oversized frame is counted and disconnected. |
|
||||||
|
| Outgoing message | 768 bytes | An over-limit message cannot enter the queue. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
## Build-time measurements
|
||||||
|
|
||||||
|
The Milestone 010 production build on 2026-09-05 reported 124,276 of 327,680
|
||||||
|
static RAM bytes and 1,028,600 of 2,097,152 application bytes. The independent
|
||||||
|
binary check measured 1,029,232 bytes in the 2 MiB factory partition, leaving
|
||||||
|
1,067,920 bytes of application margin. The compressed web
|
||||||
|
payload totals 19,253 bytes against its 1.5 MiB budget, leaving 1,553,611 bytes
|
||||||
|
of payload margin. LittleFS remains the fixed 1,984 KiB partition and its image
|
||||||
|
is checked against `partitions.csv` on every build.
|
||||||
|
|
||||||
|
The global authoritative gateway, including its one maze, one projection
|
||||||
|
buffer, fixed queues, sessions, timers, and metrics, occupies 86,840 bytes of
|
||||||
|
static storage in the production ELF. It does not allocate a maze or
|
||||||
|
projection per client. No production source or test requires PSRAM or a GPIO.
|
||||||
|
|
||||||
|
## Runtime measurements
|
||||||
|
|
||||||
|
`GET /diagnostics` returns current and minimum internal 8-bit heap values and
|
||||||
|
the largest internal free block. It deliberately samples
|
||||||
|
`MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT`, so external RAM cannot make a failing
|
||||||
|
internal-memory result appear healthy. Serial samples use the same capability
|
||||||
|
mask.
|
||||||
|
|
||||||
|
Final six-client, slow-client, repeated-match, and network-recovery samples
|
||||||
|
belong in the active milestone evidence in `PLANS.md`. Build-time numbers in
|
||||||
|
this document must be refreshed if the production image changes before that
|
||||||
|
evidence is recorded.
|
||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -14,6 +15,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
MALFORMED_STRESS_MESSAGES = 80
|
||||||
|
|
||||||
|
|
||||||
class WebSocket:
|
class WebSocket:
|
||||||
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
||||||
@@ -153,6 +156,200 @@ def open_direction(view: dict[str, Any]) -> str:
|
|||||||
raise AssertionError("player cell has no open passage")
|
raise AssertionError("player cell has no open passage")
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics(host: str) -> dict[str, Any]:
|
||||||
|
connection = http.client.HTTPConnection(host, 80, timeout=8.0)
|
||||||
|
try:
|
||||||
|
connection.request("GET", "/diagnostics")
|
||||||
|
response = connection.getresponse()
|
||||||
|
payload = response.read()
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError(f"diagnostics returned HTTP {response.status}")
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
|
||||||
|
clients: list[WebSocket] = []
|
||||||
|
try:
|
||||||
|
baseline = diagnostics(host)
|
||||||
|
player_1 = WebSocket(host, timeout=30.0)
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
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)]
|
||||||
|
clients.extend(observers)
|
||||||
|
for observer in observers:
|
||||||
|
join(observer, "observer")
|
||||||
|
|
||||||
|
observer_views: list[dict[str, Any] | None] = [None] * len(observers)
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_observer(index: int) -> None:
|
||||||
|
try:
|
||||||
|
observers[index].receive_type("active", attempts=50)
|
||||||
|
observer_views[index] = receive_view(observers[index])
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
readers = [threading.Thread(target=read_observer, args=(index,))
|
||||||
|
for index in range(len(observers))]
|
||||||
|
for reader in 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 readers:
|
||||||
|
reader.join(timeout=40.0)
|
||||||
|
assert not failures and all(not reader.is_alive() for reader in readers)
|
||||||
|
assert all(view is not None and len(view["cells"]) == 8192
|
||||||
|
for view in observer_views)
|
||||||
|
|
||||||
|
six_client_sample = diagnostics(host)
|
||||||
|
limits = six_client_sample["limits"]
|
||||||
|
assert six_client_sample["activeConnections"] == 6
|
||||||
|
assert six_client_sample["connectionHighWater"] == 6
|
||||||
|
assert six_client_sample["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
print("M010 six-client load: PASS (2 players + 4 complete observers)")
|
||||||
|
|
||||||
|
observers[0].send("restart", {}, "m010-observer-restart")
|
||||||
|
observer_error = observers[0].receive_type("error", attempts=30)
|
||||||
|
assert observer_error["payload"]["code"] == "read_only"
|
||||||
|
|
||||||
|
heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index, client in enumerate(clients):
|
||||||
|
started = time.monotonic()
|
||||||
|
client.send("heartbeat", {}, f"m010-heartbeat-{index}")
|
||||||
|
client.receive_type("ack", attempts=30)
|
||||||
|
heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
assert max(heartbeat_latencies_ms) <= limits["playerLatencyBudgetMs"]
|
||||||
|
print(f"M010 six-client responsiveness: PASS (max {max(heartbeat_latencies_ms):.1f} ms)")
|
||||||
|
|
||||||
|
slow_observer = observers[0]
|
||||||
|
slow_observer.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
|
||||||
|
drain_stop = threading.Event()
|
||||||
|
drain_failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def drain_fast_observer(observer: WebSocket) -> None:
|
||||||
|
observer.socket.settimeout(1.0)
|
||||||
|
while not drain_stop.is_set():
|
||||||
|
try:
|
||||||
|
observer.receive()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except BaseException as error: # Preserve an unexpected disconnect.
|
||||||
|
drain_failures.append(error)
|
||||||
|
return
|
||||||
|
|
||||||
|
drainers = [threading.Thread(target=drain_fast_observer, args=(observer,))
|
||||||
|
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] = []
|
||||||
|
for move_index in range(stress_moves):
|
||||||
|
time.sleep(0.16)
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("move", {"direction": direction}, f"m010-move-{move_index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
command_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
receive_view(player_2)
|
||||||
|
direction = opposite[direction]
|
||||||
|
|
||||||
|
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)
|
||||||
|
for observer in observers[1:]:
|
||||||
|
observer.socket.settimeout(30.0)
|
||||||
|
|
||||||
|
abusive_observer = observers[1]
|
||||||
|
|
||||||
|
def malformed_flood() -> None:
|
||||||
|
try:
|
||||||
|
for _ in range(MALFORMED_STRESS_MESSAGES):
|
||||||
|
abusive_observer.send_raw(b"{")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flood = threading.Thread(target=malformed_flood)
|
||||||
|
flood.start()
|
||||||
|
stress_heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index in range(10):
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("heartbeat", {}, f"m010-stress-heartbeat-{index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
stress_heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
time.sleep(0.02)
|
||||||
|
flood.join(timeout=10.0)
|
||||||
|
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 stressed["malformedMessages"] > baseline["malformedMessages"]
|
||||||
|
assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"]
|
||||||
|
assert stressed["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
assert stressed["minimumInternalFreeBytes"] > 32768
|
||||||
|
assert stressed["minimumInternalLargestBlockBytes"] > 16384
|
||||||
|
print(
|
||||||
|
"M010 slow/malformed stress: PASS "
|
||||||
|
f"(max player latency {maximum_latency_ms:.1f} ms, "
|
||||||
|
f"dropped {stressed['messagesDropped'] - baseline['messagesDropped']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
for observer in observers:
|
||||||
|
observer.close()
|
||||||
|
if observer in clients:
|
||||||
|
clients.remove(observer)
|
||||||
|
observers.clear()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
reset_heap_samples: list[int] = []
|
||||||
|
for match_index in range(repeat_matches):
|
||||||
|
token_2 = session_2["token"]
|
||||||
|
player_2.close()
|
||||||
|
clients.remove(player_2)
|
||||||
|
player_1.receive_type("pause", attempts=100)
|
||||||
|
player_1.socket.settimeout(70.0)
|
||||||
|
result = player_1.receive_type("match_result", attempts=200)["payload"]
|
||||||
|
assert result["result"] == "draw"
|
||||||
|
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
clients.append(player_2)
|
||||||
|
player_2.send("restore", {"token": token_2}, f"m010-restore-{match_index}")
|
||||||
|
session_2 = player_2.receive_type("session", attempts=50)["payload"]
|
||||||
|
player_1.send("restart", {}, f"m010-restart-{match_index}")
|
||||||
|
player_1.receive_type("ack", attempts=100)
|
||||||
|
time.sleep(0.2)
|
||||||
|
sample = diagnostics(host)
|
||||||
|
reset_heap_samples.append(sample["internalFreeBytes"])
|
||||||
|
if match_index + 1 < repeat_matches:
|
||||||
|
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)
|
||||||
|
|
||||||
|
final_sample = diagnostics(host)
|
||||||
|
assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches
|
||||||
|
assert final_sample["matchesReset"] - baseline["matchesReset"] >= repeat_matches
|
||||||
|
if len(reset_heap_samples) > 1:
|
||||||
|
assert max(reset_heap_samples) - min(reset_heap_samples) <= 32768
|
||||||
|
print(
|
||||||
|
f"M010 repeated-match stability: PASS ({repeat_matches} matches, "
|
||||||
|
f"heap range {max(reset_heap_samples) - min(reset_heap_samples)} bytes)"
|
||||||
|
)
|
||||||
|
print(json.dumps(final_sample, ensure_ascii=False, sort_keys=True))
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def verify_m009(host: str) -> None:
|
def verify_m009(host: str) -> None:
|
||||||
clients: list[WebSocket] = []
|
clients: list[WebSocket] = []
|
||||||
try:
|
try:
|
||||||
@@ -350,8 +547,17 @@ def main() -> None:
|
|||||||
parser.add_argument("host", help="maze32 IP address or hostname")
|
parser.add_argument("host", help="maze32 IP address or hostname")
|
||||||
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
||||||
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
|
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
|
||||||
|
parser.add_argument("--verify-m010", action="store_true", help="run the Milestone 010 load and stability scenario")
|
||||||
|
parser.add_argument("--stress-moves", type=int, default=80,
|
||||||
|
help="accepted moves sent while one observer is not reading")
|
||||||
|
parser.add_argument("--repeat-matches", type=int, default=2,
|
||||||
|
help="real 60-second timeout matches completed and reset")
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
if arguments.verify_m009:
|
if arguments.stress_moves < 1 or arguments.repeat_matches < 1:
|
||||||
|
parser.error("--stress-moves and --repeat-matches must be positive")
|
||||||
|
if arguments.verify_m010:
|
||||||
|
verify_m010(arguments.host, arguments.stress_moves, arguments.repeat_matches)
|
||||||
|
elif arguments.verify_m009:
|
||||||
verify_m009(arguments.host)
|
verify_m009(arguments.host)
|
||||||
elif arguments.verify:
|
elif arguments.verify:
|
||||||
verify(arguments.host)
|
verify(arguments.host)
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ static void test_protocol_schema(void) {
|
|||||||
const char* drop_key = "{\"v\":1,\"type\":\"drop_key\",\"seq\":4,\"payload\":{}}";
|
const char* drop_key = "{\"v\":1,\"type\":\"drop_key\",\"seq\":4,\"payload\":{}}";
|
||||||
assert(maze32_protocol_parse(drop_key, strlen(drop_key), &message) == MAZE32_PROTOCOL_OK);
|
assert(maze32_protocol_parse(drop_key, strlen(drop_key), &message) == MAZE32_PROTOCOL_OK);
|
||||||
assert(message.type == MAZE32_MESSAGE_DROP_KEY);
|
assert(message.type == MAZE32_MESSAGE_DROP_KEY);
|
||||||
|
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
|
||||||
|
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
|
||||||
|
assert(message.type == MAZE32_MESSAGE_RESTART);
|
||||||
const char* diagonal =
|
const char* diagonal =
|
||||||
"{\"v\":1,\"type\":\"move\",\"seq\":5,\"payload\":{\"direction\":\"northwest\"}}";
|
"{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}";
|
||||||
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
|
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
|
||||||
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
||||||
|
|
||||||
@@ -109,6 +112,21 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
|||||||
assert(!maze32_lobby_activate_if_due(&lobby, 4999U));
|
assert(!maze32_lobby_activate_if_due(&lobby, 4999U));
|
||||||
assert(maze32_lobby_activate_if_due(&lobby, 5000U));
|
assert(maze32_lobby_activate_if_due(&lobby, 5000U));
|
||||||
assert(lobby.phase == MAZE32_LOBBY_ACTIVE);
|
assert(lobby.phase == MAZE32_LOBBY_ACTIVE);
|
||||||
|
maze32_session_t sessions_before_reset[MAZE32_SESSION_LIMIT];
|
||||||
|
memcpy(sessions_before_reset, lobby.sessions, sizeof(sessions_before_reset));
|
||||||
|
for (uint64_t cycle = 0U; cycle < 128U; ++cycle) {
|
||||||
|
revision = lobby.revision;
|
||||||
|
maze32_lobby_reset_match(&lobby);
|
||||||
|
assert(lobby.phase == MAZE32_LOBBY_WAITING);
|
||||||
|
assert(lobby.countdown_started_ms == 0U && lobby.activation_ms == 0U);
|
||||||
|
assert(lobby.revision == revision + 1U);
|
||||||
|
assert(maze32_lobby_ready(&lobby));
|
||||||
|
assert(memcmp(lobby.sessions, sessions_before_reset, sizeof(sessions_before_reset)) == 0);
|
||||||
|
uint64_t started_ms = 10000U + cycle * 4000U;
|
||||||
|
assert(maze32_lobby_start(&lobby, 10, started_ms) == MAZE32_LOBBY_OK);
|
||||||
|
assert(maze32_lobby_activate_if_due(&lobby, started_ms + MAZE32_COUNTDOWN_DURATION_MS));
|
||||||
|
}
|
||||||
|
puts("lobby repeated-match reset: PASS (128 cycles)");
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
|
|||||||
@@ -104,3 +104,10 @@ test("observer protocol exposes no mutation control path", () => {
|
|||||||
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
||||||
assert.equal("self" in snapshot, false);
|
assert.equal("self" in snapshot, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("finished matches expose only an owner restart intention", () => {
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/);
|
||||||
|
assert.match(script, /message\.type === "reset"/);
|
||||||
|
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.9.0
|
0.10.0
|
||||||
|
|||||||
+9
-1
@@ -358,6 +358,7 @@
|
|||||||
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
||||||
}
|
}
|
||||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||||
|
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
|
||||||
byId("modal-action").hidden = false;
|
byId("modal-action").hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +394,7 @@
|
|||||||
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
|
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
|
||||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||||
|
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||||
} else if (message.type === "ack") { feedback(true);
|
} else if (message.type === "ack") { feedback(true);
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
||||||
@@ -438,7 +440,13 @@
|
|||||||
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
||||||
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
||||||
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||||
byId("modal-action").addEventListener("click", () => { hideModal(); setScreen("lobby"); });
|
byId("modal-action").addEventListener("click", () => {
|
||||||
|
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
|
||||||
|
send("restart", {}, byId("modal-action"));
|
||||||
|
} else {
|
||||||
|
hideModal(); setScreen("lobby");
|
||||||
|
}
|
||||||
|
});
|
||||||
connect();
|
connect();
|
||||||
return {state, handleMessage, move};
|
return {state, handleMessage, move};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user