feat: harden errors, recovery, and resource usage
This commit is contained in:
@@ -823,7 +823,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
|
|||||||
|
|
||||||
## Milestone 016 — Harden errors, recovery, and resource usage
|
## Milestone 016 — Harden errors, recovery, and resource usage
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 015
|
**Depends on:** Milestone 015
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -851,11 +851,22 @@ Make the integrated application resilient to malformed traffic, connection churn
|
|||||||
|
|
||||||
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 017 from `BLOCKED` to `READY`.
|
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 017 from `BLOCKED` to `READY`.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-08-29
|
||||||
|
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
|
||||||
|
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
|
||||||
|
- Result: PASS for automated hardening verification; pending on-device soak and recovery confirmation.
|
||||||
|
- Evidence: Rejection diagnostics are now saturating and protected against concurrent callback updates. `GET /api/health` includes the platform reset reason alongside uptime, heap, minimum heap, largest free block, connection count, and rejected-input count. Added bounded malformed-traffic coverage that repeatedly exercises all HTTP command parsers and 600 randomized WebSocket frames with open/close churn; invalid traffic produces bounded errors without lifecycle mutation. Existing fixed session, WebSocket hello timeout, delivery failure removal, reconnect/polling, and queue limits remain in effect.
|
||||||
|
- Measurements: `make -C test/host run` passed command queue, domain, bot strategy, lifecycle, presenter, HTTP API, synchronization, human-game integration, bot-game integration, and robustness suites. The robustness suite covers 400 malformed HTTP requests, oversized bodies, 600 randomized WebSocket frames, oversized WebSocket frames, and repeated connection-slot reuse. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,018,786 / 2,097,152 B flash (48.6%), both within final budget limits.
|
||||||
|
- Issues or deviations: No firmware upload, Wi-Fi interruption, 30-minute soak, or live heap/latency measurement was performed. Those remain mandatory physical-device checks before release acceptance.
|
||||||
|
- Next action: Milestone 017 is ready. Do not start it unless explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 017 — Execute final MVP acceptance and create the release baseline
|
## Milestone 017 — Execute final MVP acceptance and create the release baseline
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `READY`
|
||||||
**Depends on:** Milestone 016
|
**Depends on:** Milestone 016
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# API contract v1
|
# API contract v1
|
||||||
|
|
||||||
|
Machine-readable HTTP documentation: [openapi.yaml](openapi.yaml).
|
||||||
|
|
||||||
All JSON is UTF-8 and uses `Content-Type: application/json`. API responses set
|
All JSON is UTF-8 and uses `Content-Type: application/json`. API responses set
|
||||||
`Cache-Control: no-store`. A request exceeding its route limit is rejected
|
`Cache-Control: no-store`. A request exceeding its route limit is rejected
|
||||||
before parsing with `PAYLOAD_TOO_LARGE`; malformed JSON is `MALFORMED_JSON`.
|
before parsing with `PAYLOAD_TOO_LARGE`; malformed JSON is `MALFORMED_JSON`.
|
||||||
@@ -8,7 +10,7 @@ All numeric fields are decimal JSON integers, never strings.
|
|||||||
## Common values
|
## Common values
|
||||||
|
|
||||||
| Type | Values / bound |
|
| Type | Values / bound |
|
||||||
| ------------------- | --------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `role` | `player1`, `player2`, `spectator` |
|
| `role` | `player1`, `player2`, `spectator` |
|
||||||
| `mode` | `human`, `bot` |
|
| `mode` | `human`, `bot` |
|
||||||
| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` |
|
| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` |
|
||||||
@@ -46,7 +48,7 @@ Failure (maximum 160 encoded bytes):
|
|||||||
| Route | Maximum request | Response / maximum |
|
| Route | Maximum request | Response / maximum |
|
||||||
| -------------------------- | --------------: | --------------------------------------------- |
|
| -------------------------- | --------------: | --------------------------------------------- |
|
||||||
| `GET /api/info` | 128 B target | public device/slot state, 192 B |
|
| `GET /api/info` | 128 B target | public device/slot state, 192 B |
|
||||||
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B |
|
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
||||||
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
||||||
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
||||||
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
|
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ bounded snapshot at a time into the 512-byte transport buffer.
|
|||||||
## Per-milestone budget gates
|
## Per-milestone budget gates
|
||||||
|
|
||||||
| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check |
|
| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check |
|
||||||
|---|---:|---:|---:|---|
|
| ---------------- | ---------------: | ---------------: | ---------: | ---------------------------------- |
|
||||||
| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests |
|
| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests |
|
||||||
| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests |
|
| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests |
|
||||||
| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input |
|
| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input |
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Battleship ESP32 API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Local HTTP API for the ESP32-C6 Battleship MVP. All responses are JSON,
|
||||||
|
UTF-8, and carry `Cache-Control: no-store`. The ESP32 is authoritative.
|
||||||
|
WebSocket synchronization at `/ws` is documented in API_CONTRACT.md;
|
||||||
|
it is not an HTTP request/response operation and is therefore outside
|
||||||
|
this OpenAPI document.
|
||||||
|
servers:
|
||||||
|
- url: http://{device-address}
|
||||||
|
variables:
|
||||||
|
device-address:
|
||||||
|
default: 192.168.1.50
|
||||||
|
description: DHCP address of the ESP32 on the local network.
|
||||||
|
paths:
|
||||||
|
/api/info:
|
||||||
|
get:
|
||||||
|
summary: Read public device and slot availability
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Public game metadata.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Info'
|
||||||
|
/api/health:
|
||||||
|
get:
|
||||||
|
summary: Read device diagnostics without secrets
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current resource and network diagnostics.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Health'
|
||||||
|
/api/session/join:
|
||||||
|
post:
|
||||||
|
summary: Create a session and request a role
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/JoinRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionCreated'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
/api/session/resume:
|
||||||
|
post:
|
||||||
|
summary: Resume a role using its opaque token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TokenRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session resumed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResumed'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
/api/game/config:
|
||||||
|
post:
|
||||||
|
summary: Set the game mode (Player 1, lobby only)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ConfigRequest'
|
||||||
|
responses: &commandResponses
|
||||||
|
'200': { $ref: '#/components/responses/CommandAccepted' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
'503': { $ref: '#/components/responses/ServerBusy' }
|
||||||
|
/api/game/start:
|
||||||
|
post:
|
||||||
|
summary: Start a configured game (Player 1)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameRequest'
|
||||||
|
responses: *commandResponses
|
||||||
|
/api/game/shot:
|
||||||
|
post:
|
||||||
|
summary: Fire at an opponent cell
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ShotRequest'
|
||||||
|
responses: *commandResponses
|
||||||
|
/api/game/rematch:
|
||||||
|
post:
|
||||||
|
summary: Confirm a rematch after a finished game
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameRequest'
|
||||||
|
responses: *commandResponses
|
||||||
|
/api/game/abort:
|
||||||
|
post:
|
||||||
|
summary: Abort a human game with a disconnected opponent
|
||||||
|
description: Available only to Player 1 in the locked human-versus-human case.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameRequest'
|
||||||
|
responses: *commandResponses
|
||||||
|
/api/state:
|
||||||
|
get:
|
||||||
|
summary: Get a complete role-safe state snapshot
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Version'
|
||||||
|
- $ref: '#/components/parameters/SessionToken'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: State view for the token role; without a token, a spectator-safe view.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/State'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
/api/statistics:
|
||||||
|
get:
|
||||||
|
summary: Get match and reboot-scoped cumulative statistics
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/SessionToken'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Statistics only; no board cells are returned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Statistics'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Version:
|
||||||
|
name: version
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: { type: integer, minimum: 0, maximum: 4294967295 }
|
||||||
|
description: Last applied state version; a complete snapshot is always safe to consume.
|
||||||
|
SessionToken:
|
||||||
|
name: X-Session-Token
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema: { $ref: '#/components/schemas/Token' }
|
||||||
|
description: Omit only when a spectator-safe public view is intended.
|
||||||
|
responses:
|
||||||
|
CommandAccepted:
|
||||||
|
description: Command accepted by the authoritative application layer.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/CommandAccepted' }
|
||||||
|
BadRequest:
|
||||||
|
description: Malformed JSON or invalid input.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
Unauthorized:
|
||||||
|
description: Invalid or expired token.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
Forbidden:
|
||||||
|
description: The role may not perform this command.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
Conflict:
|
||||||
|
description: Stale game, wrong phase, unavailable slot, or invalid turn/cell state.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
PayloadTooLarge:
|
||||||
|
description: Request body or target exceeded its route bound.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
ServerBusy:
|
||||||
|
description: Bounded command queue or serializer is unavailable.
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
|
||||||
|
schemas:
|
||||||
|
Token:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{32}$'
|
||||||
|
description: Opaque, reboot-scoped session token.
|
||||||
|
Role:
|
||||||
|
type: string
|
||||||
|
enum: [player1, player2, spectator]
|
||||||
|
Mode:
|
||||||
|
type: string
|
||||||
|
enum: [human, bot]
|
||||||
|
Phase:
|
||||||
|
type: string
|
||||||
|
enum: [lobby, preparing, in_progress, finished, rematch_wait]
|
||||||
|
GameId:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 4294967295
|
||||||
|
JoinRequest:
|
||||||
|
type: object
|
||||||
|
required: [name, requestedRole]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
name: { type: string, minLength: 1, maxLength: 80, description: 1–20 Unicode scalar values after server validation. }
|
||||||
|
requestedRole: { $ref: '#/components/schemas/Role' }
|
||||||
|
TokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [token]
|
||||||
|
additionalProperties: false
|
||||||
|
properties: { token: { $ref: '#/components/schemas/Token' } }
|
||||||
|
GameRequest:
|
||||||
|
type: object
|
||||||
|
required: [token, gameId]
|
||||||
|
properties:
|
||||||
|
token: { $ref: '#/components/schemas/Token' }
|
||||||
|
gameId: { $ref: '#/components/schemas/GameId' }
|
||||||
|
ConfigRequest:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/GameRequest'
|
||||||
|
- type: object
|
||||||
|
required: [mode]
|
||||||
|
properties: { mode: { $ref: '#/components/schemas/Mode' } }
|
||||||
|
ShotRequest:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/GameRequest'
|
||||||
|
- type: object
|
||||||
|
required: [x, y]
|
||||||
|
properties:
|
||||||
|
x: { type: integer, minimum: 0, maximum: 9 }
|
||||||
|
y: { type: integer, minimum: 0, maximum: 9 }
|
||||||
|
CommandAccepted:
|
||||||
|
type: object
|
||||||
|
required: [ok, version, gameId]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [true] }
|
||||||
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
|
gameId: { $ref: '#/components/schemas/GameId' }
|
||||||
|
SessionCreated:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/CommandAccepted'
|
||||||
|
- type: object
|
||||||
|
required: [token, role]
|
||||||
|
properties:
|
||||||
|
token: { $ref: '#/components/schemas/Token' }
|
||||||
|
role: { $ref: '#/components/schemas/Role' }
|
||||||
|
SessionResumed:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/CommandAccepted'
|
||||||
|
- type: object
|
||||||
|
required: [role]
|
||||||
|
properties: { role: { $ref: '#/components/schemas/Role' } }
|
||||||
|
Info:
|
||||||
|
type: object
|
||||||
|
required: [ok, phase, gameId, version, player1Available, player2Available, spectatorsAvailable]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [true] }
|
||||||
|
phase: { $ref: '#/components/schemas/Phase' }
|
||||||
|
gameId: { $ref: '#/components/schemas/GameId' }
|
||||||
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
|
player1Available: { type: boolean }
|
||||||
|
player2Available: { type: boolean }
|
||||||
|
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
|
||||||
|
Health:
|
||||||
|
type: object
|
||||||
|
required: [ok, uptimeMs, wifiState, freeHeapBytes, minimumFreeHeapBytes, largestFreeBlockBytes, connectedClients, rejectedInput, resetReason]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [true] }
|
||||||
|
uptimeMs: { $ref: '#/components/schemas/GameId' }
|
||||||
|
wifiState: { type: string, enum: [not_configured, connecting, connected] }
|
||||||
|
freeHeapBytes: { $ref: '#/components/schemas/GameId' }
|
||||||
|
minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' }
|
||||||
|
largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' }
|
||||||
|
connectedClients: { type: integer, minimum: 0, maximum: 12 }
|
||||||
|
rejectedInput: { type: integer, minimum: 0, maximum: 65535 }
|
||||||
|
resetReason: { type: integer }
|
||||||
|
Board:
|
||||||
|
type: string
|
||||||
|
pattern: '^[01234]{100}$'
|
||||||
|
description: 0 unknown/water, 1 revealed ship, 2 miss, 3 hit, 4 sunk hit.
|
||||||
|
MatchStatistics:
|
||||||
|
type: array
|
||||||
|
minItems: 4
|
||||||
|
maxItems: 4
|
||||||
|
items: { type: integer, minimum: 0 }
|
||||||
|
description: '[shots, hits, misses, shipsSunk]'
|
||||||
|
CumulativeStatistics:
|
||||||
|
type: array
|
||||||
|
minItems: 7
|
||||||
|
maxItems: 7
|
||||||
|
items: { type: integer, minimum: 0 }
|
||||||
|
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
|
||||||
|
State:
|
||||||
|
type: object
|
||||||
|
required: [type, version, gameId, phase, mode, viewer, turn, boards, wins, winner, statistics]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [state] }
|
||||||
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
|
gameId: { $ref: '#/components/schemas/GameId' }
|
||||||
|
phase: { $ref: '#/components/schemas/Phase' }
|
||||||
|
mode: { $ref: '#/components/schemas/Mode' }
|
||||||
|
viewer: { $ref: '#/components/schemas/Role' }
|
||||||
|
turn: { type: string, enum: [player1, player2] }
|
||||||
|
boards:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { $ref: '#/components/schemas/Board' }
|
||||||
|
wins:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { type: integer, minimum: 0 }
|
||||||
|
winner:
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
enum: [0, 1]
|
||||||
|
statistics:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { $ref: '#/components/schemas/MatchStatistics' }
|
||||||
|
Statistics:
|
||||||
|
type: object
|
||||||
|
required: [ok, viewer, gameId, match, cumulative]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [true] }
|
||||||
|
viewer: { $ref: '#/components/schemas/Role' }
|
||||||
|
gameId: { $ref: '#/components/schemas/GameId' }
|
||||||
|
match:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { $ref: '#/components/schemas/MatchStatistics' }
|
||||||
|
cumulative:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { $ref: '#/components/schemas/CumulativeStatistics' }
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
required: [ok, code, message, version]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [false] }
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum: [MALFORMED_JSON, PAYLOAD_TOO_LARGE, INVALID_NAME, INVALID_ROLE, INVALID_MODE, INVALID_COORDINATE, UNAUTHORIZED, NO_PLAYER_SLOT, NO_SPECTATOR_SLOT, FORBIDDEN_ROLE, WRONG_PHASE, NOT_YOUR_TURN, CELL_ALREADY_SHOT, STALE_GAME, SERVER_BUSY]
|
||||||
|
message: { type: string, maxLength: 80, description: Russian user-facing text. }
|
||||||
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
@@ -29,6 +29,7 @@ typedef struct {
|
|||||||
uint32_t largest_free_block_bytes;
|
uint32_t largest_free_block_bytes;
|
||||||
uint8_t connected_clients;
|
uint8_t connected_clients;
|
||||||
uint16_t rejected_input;
|
uint16_t rejected_input;
|
||||||
|
int8_t reset_reason;
|
||||||
const char *wifi_state;
|
const char *wifi_state;
|
||||||
} http_api_health_t;
|
} http_api_health_t;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -277,10 +277,10 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
|
|||||||
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
|
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
|
||||||
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
|
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
|
||||||
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
|
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
|
||||||
",\"connectedClients\":%u,\"rejectedInput\":%u}", api->health.uptime_ms,
|
",\"connectedClients\":%u,\"rejectedInput\":%u,\"resetReason\":%d}", api->health.uptime_ms,
|
||||||
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
|
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
|
||||||
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
|
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
|
||||||
api->health.connected_clients, api->health.rejected_input);
|
api->health.connected_clients, api->health.rejected_input, api->health.reset_reason);
|
||||||
return response->body_length <= 320U;
|
return response->body_length <= 320U;
|
||||||
}
|
}
|
||||||
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
|
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
|
||||||
|
|||||||
+23
-7
@@ -36,6 +36,7 @@ static const uint8_t kHttpMaxOpenSockets = 12U;
|
|||||||
|
|
||||||
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
|
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
|
||||||
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static wifi_state_t s_wifi_state = {0};
|
static wifi_state_t s_wifi_state = {0};
|
||||||
static bool s_littlefs_mounted;
|
static bool s_littlefs_mounted;
|
||||||
static httpd_handle_t s_server;
|
static httpd_handle_t s_server;
|
||||||
@@ -51,6 +52,20 @@ static esp_timer_handle_t s_reconnect_timer;
|
|||||||
|
|
||||||
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
||||||
|
|
||||||
|
static void record_rejected_input(void) {
|
||||||
|
portENTER_CRITICAL(&s_diagnostics_lock);
|
||||||
|
if (s_rejected_input < UINT16_MAX) ++s_rejected_input;
|
||||||
|
portEXIT_CRITICAL(&s_diagnostics_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t rejected_input_snapshot(void) {
|
||||||
|
uint16_t rejected = 0;
|
||||||
|
portENTER_CRITICAL(&s_diagnostics_lock);
|
||||||
|
rejected = s_rejected_input;
|
||||||
|
portEXIT_CRITICAL(&s_diagnostics_lock);
|
||||||
|
return rejected;
|
||||||
|
}
|
||||||
|
|
||||||
static wifi_state_t wifi_state_snapshot(void) {
|
static wifi_state_t wifi_state_snapshot(void) {
|
||||||
wifi_state_t snapshot;
|
wifi_state_t snapshot;
|
||||||
portENTER_CRITICAL(&s_wifi_lock);
|
portENTER_CRITICAL(&s_wifi_lock);
|
||||||
@@ -75,7 +90,8 @@ static void refresh_health(void) {
|
|||||||
.minimum_free_heap_bytes = esp_get_minimum_free_heap_size(),
|
.minimum_free_heap_bytes = esp_get_minimum_free_heap_size(),
|
||||||
.largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
|
.largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
|
||||||
.connected_clients = (uint8_t)clients,
|
.connected_clients = (uint8_t)clients,
|
||||||
.rejected_input = s_rejected_input,
|
.rejected_input = rejected_input_snapshot(),
|
||||||
|
.reset_reason = (int8_t)esp_reset_reason(),
|
||||||
.wifi_state = wifi_state_name(&wifi_state),
|
.wifi_state = wifi_state_name(&wifi_state),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,7 +124,7 @@ static void sync_broadcast_work(void *unused) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void queue_state_broadcast(void) {
|
static void queue_state_broadcast(void) {
|
||||||
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) ++s_rejected_input;
|
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) record_rejected_input();
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
|
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
|
||||||
@@ -124,7 +140,7 @@ static void bot_turn_work(void *unused) {
|
|||||||
|
|
||||||
static void bot_timer_callback(void *unused) {
|
static void bot_timer_callback(void *unused) {
|
||||||
(void)unused;
|
(void)unused;
|
||||||
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) ++s_rejected_input;
|
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) record_rejected_input();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void sync_expire_work(void *unused) {
|
static void sync_expire_work(void *unused) {
|
||||||
@@ -167,18 +183,18 @@ static esp_err_t api_handler(httpd_req_t *request) {
|
|||||||
const size_t maximum = route_body_limit(route);
|
const size_t maximum = route_body_limit(route);
|
||||||
size_t body_length = 0U;
|
size_t body_length = 0U;
|
||||||
if (request->method == HTTP_POST) {
|
if (request->method == HTTP_POST) {
|
||||||
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; ++s_rejected_input; }
|
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); }
|
||||||
else {
|
else {
|
||||||
while (body_length < (size_t)request->content_len) {
|
while (body_length < (size_t)request->content_len) {
|
||||||
const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length);
|
const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length);
|
||||||
if (received <= 0) { ++s_rejected_input; body_length = maximum + 1U; break; }
|
if (received <= 0) { record_rejected_input(); body_length = maximum + 1U; break; }
|
||||||
body_length += (size_t)received;
|
body_length += (size_t)received;
|
||||||
}
|
}
|
||||||
body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0';
|
body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
|
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
|
||||||
if (target_too_large) ++s_rejected_input;
|
if (target_too_large) record_rejected_input();
|
||||||
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
|
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
|
||||||
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
|
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
|
||||||
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
|
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
|
||||||
@@ -205,7 +221,7 @@ static esp_err_t websocket_handler(httpd_req_t *request) {
|
|||||||
httpd_ws_frame_t frame = {0};
|
httpd_ws_frame_t frame = {0};
|
||||||
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
|
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
|
||||||
frame.len > kWebSocketFrameCapacity) {
|
frame.len > kWebSocketFrameCapacity) {
|
||||||
++s_rejected_input;
|
record_rejected_input();
|
||||||
sync_service_close(&s_sync, client_id);
|
sync_service_close(&s_sync, client_id);
|
||||||
httpd_sess_trigger_close(s_server, client_id);
|
httpd_sess_trigger_close(s_server, client_id);
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
|
|||||||
+7
-2
@@ -1,7 +1,7 @@
|
|||||||
CC ?= cc
|
CC ?= cc
|
||||||
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
||||||
|
|
||||||
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration
|
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
|
||||||
|
|
||||||
test_command_queue: test_command_queue.c ../../src/command_queue.c
|
test_command_queue: test_command_queue.c ../../src/command_queue.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -16,6 +16,7 @@ run: all
|
|||||||
./test_sync_service
|
./test_sync_service
|
||||||
./test_human_game_integration
|
./test_human_game_integration
|
||||||
./test_bot_game_integration
|
./test_bot_game_integration
|
||||||
|
./test_robustness
|
||||||
|
|
||||||
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -41,5 +42,9 @@ test_human_game_integration: test_human_game_integration.c ../../src/http_api.c
|
|||||||
test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
|
test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
|
||||||
|
|
||||||
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration
|
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ static http_api_t new_api(application_t *application, test_random_t *random) {
|
|||||||
http_api_init(&api, application);
|
http_api_init(&api, application);
|
||||||
http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U,
|
http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U,
|
||||||
.minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U,
|
.minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U,
|
||||||
.connected_clients = 2U, .rejected_input = 3U, .wifi_state = "connected"});
|
.connected_clients = 2U, .rejected_input = 3U, .reset_reason = 11, .wifi_state = "connected"});
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ static void test_public_routes_and_parse_limits(void) {
|
|||||||
http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
|
http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
|
||||||
assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL);
|
assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL);
|
||||||
response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL);
|
response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL);
|
||||||
assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL);
|
assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL &&
|
||||||
|
strstr(response.body, "\"resetReason\":11") != NULL);
|
||||||
const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true};
|
const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true};
|
||||||
assert(http_api_handle(&api, &long_target, &response));
|
assert(http_api_handle(&api, &long_target, &response));
|
||||||
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
|
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "http_api.h"
|
||||||
|
#include "sync_service.h"
|
||||||
|
|
||||||
|
typedef struct { uint32_t value; } test_random_t;
|
||||||
|
|
||||||
|
static uint32_t next_random(void *context) {
|
||||||
|
test_random_t *random = context;
|
||||||
|
random->value = random->value * 1664525U + 1013904223U;
|
||||||
|
return random->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static application_t new_application(test_random_t *random) {
|
||||||
|
application_t application;
|
||||||
|
application_init(&application, (random_source_t){.next_u32 = next_random, .context = random});
|
||||||
|
return application;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_http_malformed_inputs_do_not_mutate(void) {
|
||||||
|
test_random_t random = {.value = 7U};
|
||||||
|
application_t application = new_application(&random);
|
||||||
|
http_api_t api;
|
||||||
|
http_api_init(&api, &application);
|
||||||
|
const char *const corpus[] = {"", "{", "[]", "{\"token\":", "{\"token\":null}",
|
||||||
|
"{\"name\":\"A\"}", "{\"token\":\"00000000000000000000000000000000\",\"gameId\":-1}",
|
||||||
|
"{\"type\":\"shot\"}", "{\"name\":\"A\",\"requestedRole\":\"player1\",\"extra\":1}"};
|
||||||
|
const http_api_route_t routes[] = {HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_RESUME, HTTP_API_ROUTE_CONFIG,
|
||||||
|
HTTP_API_ROUTE_START, HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT};
|
||||||
|
for (size_t index = 0; index < 400U; ++index) {
|
||||||
|
const char *body = corpus[index % (sizeof(corpus) / sizeof(corpus[0]))];
|
||||||
|
const game_lifecycle_t before = application.lifecycle;
|
||||||
|
http_api_response_t response;
|
||||||
|
const http_api_request_t request = {.method = HTTP_API_POST, .route = routes[index % (sizeof(routes) / sizeof(routes[0]))],
|
||||||
|
.content_type_json = true, .body = body, .body_length = strlen(body)};
|
||||||
|
assert(http_api_handle(&api, &request, &response));
|
||||||
|
assert(response.status >= 400U && response.status < 600U && response.body_length < sizeof(response.body));
|
||||||
|
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
|
||||||
|
}
|
||||||
|
char oversized[kRequestBodyCapacity + 2U];
|
||||||
|
memset(oversized, 'x', sizeof(oversized) - 1U);
|
||||||
|
oversized[sizeof(oversized) - 1U] = '\0';
|
||||||
|
http_api_response_t response;
|
||||||
|
const http_api_request_t request = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN, .content_type_json = true,
|
||||||
|
.body = oversized, .body_length = strlen(oversized)};
|
||||||
|
assert(http_api_handle(&api, &request, &response));
|
||||||
|
assert(response.status == 413U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_websocket_fuzz_and_connection_churn(void) {
|
||||||
|
test_random_t random = {.value = 19U};
|
||||||
|
application_t application = new_application(&random);
|
||||||
|
sync_service_t service;
|
||||||
|
sync_service_init(&service, &application);
|
||||||
|
for (uint16_t attempt = 0; attempt < 600U; ++attempt) {
|
||||||
|
char frame[kWebSocketFrameCapacity + 1U];
|
||||||
|
const size_t length = (size_t)(next_random(&random) % (kWebSocketFrameCapacity + 1U));
|
||||||
|
for (size_t index = 0; index < length; ++index) frame[index] = (char)(next_random(&random) & 0x7fU);
|
||||||
|
frame[length] = '\0';
|
||||||
|
const game_lifecycle_t before = application.lifecycle;
|
||||||
|
char output[kStateMessageCapacity] = {0};
|
||||||
|
size_t output_length = 0U;
|
||||||
|
bool changed = true;
|
||||||
|
bool close = false;
|
||||||
|
assert(sync_service_open(&service, 1, attempt));
|
||||||
|
assert(sync_service_receive(&service, 1, frame, length, attempt, output, &output_length, &changed, &close));
|
||||||
|
assert(!changed && output_length < sizeof(output));
|
||||||
|
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
|
||||||
|
sync_service_close(&service, 1);
|
||||||
|
}
|
||||||
|
char too_large[kWebSocketFrameCapacity + 1U] = {0};
|
||||||
|
char output[kStateMessageCapacity] = {0};
|
||||||
|
size_t output_length = 0U;
|
||||||
|
bool changed = false;
|
||||||
|
bool close = false;
|
||||||
|
assert(sync_service_open(&service, 1, 0U));
|
||||||
|
assert(sync_service_receive(&service, 1, too_large, sizeof(too_large), 0U, output, &output_length, &changed, &close));
|
||||||
|
assert(!changed && strstr(output, "MALFORMED_JSON") != NULL);
|
||||||
|
sync_service_close(&service, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_http_malformed_inputs_do_not_mutate();
|
||||||
|
test_websocket_fuzz_and_connection_churn();
|
||||||
|
puts("robustness tests passed");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user