fix: protocol 2 core review findings

From the review of #99:
- A sync could stall when a client took a slot the moment another left:
  loop() used up the new client's sync on the old one. The slots move
  to ProtoSlots, a pure module with native tests, and loop() takes a
  slot's wants only for the client it read (takeWants).
- Deleting a group sends its shades, whose inGroup changed. Deleting a
  shade unlinks it from its groups and sends them, so a new shade given
  its id doesn't join them.
- next and sync with an id get an ok reply, and a message in pieces
  gets one bad request, not one per piece.
- Feeds check for loop() before they write the event buffer, and log
  an event too large, as events do.
- editChanges() (ModelEditor, native tests) says what clients get after
  an edit; the edit result carries the shade a group gained or lost.
  order() takes a sort_list_t.
- The frame writers move to ProtoFrame. Renames: pf to proto_field_t,
  the X-macro letters to TYPE/FIELD/ERR/FEED, proto_request_t to
  proto_message_t, REQ_* to WANTS_*, fmt to eventJson. The event
  bodies share sendItem(), the connection type name lives in
  ESPNetwork, and the loop() task check in SocketEmitter.
- e2e: the heap floor check skips when the low since boot is already
  under it; tests for replies to next and sync and a long message.
- SECURITY_PLAN.md splits step 1 into 1a-1d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 13:17:15 +02:00
parent d869bb53d5
commit dc2f1478b6
29 changed files with 1087 additions and 670 deletions

View file

@ -0,0 +1,172 @@
#include <unity.h>
#include "ProtoSlots.h"
// Protocol 2's client slots and the pace of their syncs: what async_tcp and
// loop() each do with a slot, in the orders the two tasks can interleave.
static proto_slot_t slots[PROTO_MAX_CLIENTS];
void setUp() {
for(proto_slot_t &s : slots) {
freeSlot(s);
s.loopId = 0;
s.cur = sync_cursor_t();
s.awaitingNext = false;
s.sentAt = 0;
}
}
void tearDown() {}
// A client as onConnect sets it up: a slot, then hello, then the sync.
static proto_slot_t &connect(uint32_t id, bool withSchema = true) {
proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, id, withSchema);
TEST_ASSERT_NOT_NULL(slot);
startSync(*slot);
return *slot;
}
// What sendSyncFrame does with the cursor: on to the end, or one frame in.
static void sendAll(proto_slot_t &slot, uint32_t now) {
slot.cur.section = sync_section_t::done;
syncFrameSent(slot, now);
}
static void sendSome(proto_slot_t &slot, uint32_t now) {
slot.cur.section = sync_section_t::shades;
syncFrameSent(slot, now);
}
/*********************************************************************
* Slots
********************************************************************/
static void test_a_sixth_client_gets_no_slot() {
for(uint32_t id = 1; id <= PROTO_MAX_CLIENTS; id++) connect(id);
TEST_ASSERT_NULL(claimSlot(slots, PROTO_MAX_CLIENTS, 99, true));
}
static void test_a_freed_slot_is_taken_again_clean() {
proto_slot_t &a = connect(1);
a.feeds.fetch_or(0x02);
a.wants.fetch_or(WANTS_NEXT);
freeSlot(a);
TEST_ASSERT_NULL(slotOf(slots, PROTO_MAX_CLIENTS, 1));
proto_slot_t *b = claimSlot(slots, PROTO_MAX_CLIENTS, 2, false);
TEST_ASSERT_EQUAL_PTR(&a, b);
TEST_ASSERT_EQUAL_UINT8(0, b->feeds.load());
TEST_ASSERT_EQUAL_UINT32(0, b->wants.load());
TEST_ASSERT_FALSE(b->withSchema.load());
}
static void test_no_sync_before_hello() {
// claimSlot alone, as onConnect has it before hello has gone out.
proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, 1, true);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(*slot, 0));
startSync(*slot);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(*slot, 0));
}
/*********************************************************************
* The sync's pace
********************************************************************/
static void test_a_new_client_gets_the_schema_unless_it_has_it() {
proto_slot_t &a = connect(1, true);
proto_slot_t &b = connect(2, false);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 0));
TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(b, 0));
TEST_ASSERT_EQUAL(sync_section_t::header, b.cur.section);
TEST_ASSERT_EQUAL_UINT32(2, b.loopId);
}
static void test_the_next_frame_waits_for_next() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 100);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200));
a.wants.fetch_or(WANTS_NEXT);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 300));
}
static void test_next_after_the_sync_has_ended_does_nothing() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendAll(a, 100);
a.wants.fetch_or(WANTS_NEXT);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200));
}
static void test_a_sync_again_starts_over_without_the_schema() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 100);
a.wants.fetch_or(WANTS_SYNC_AGAIN);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 200));
TEST_ASSERT_EQUAL(sync_section_t::header, a.cur.section);
}
static void test_no_next_for_30s_closes_the_client_once() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 1000);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1000 + PROTO_SYNC_TIMEOUT_MS));
TEST_ASSERT_EQUAL(sync_step_t::timedOut, nextSyncStep(a, 1001 + PROTO_SYNC_TIMEOUT_MS));
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 2000 + PROTO_SYNC_TIMEOUT_MS));
}
static void test_a_finished_sync_never_times_out() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendAll(a, 0);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 10 * PROTO_SYNC_TIMEOUT_MS));
}
/*********************************************************************
* A slot changing hands under loop()
********************************************************************/
static void test_a_client_that_left_mid_sync_is_forgotten() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 0);
freeSlot(a);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1));
TEST_ASSERT_FALSE(a.awaitingNext);
TEST_ASSERT_EQUAL_UINT32(0, a.loopId);
}
static void test_the_next_client_in_the_slot_gets_its_own_sync() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 0);
freeSlot(a);
connect(2);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1));
TEST_ASSERT_EQUAL_UINT32(2, a.loopId);
TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section);
}
static void test_wants_taken_for_a_client_that_left_stay_for_the_next() {
// loop() read client 1, then 1 left and 2 took the slot before loop() took
// what it wanted: 2's sync must not be used up on 1.
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
freeSlot(a);
connect(2);
TEST_ASSERT_EQUAL_UINT32(0, takeWants(a, 1));
TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, a.wants.load());
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1));
TEST_ASSERT_EQUAL_UINT32(2, a.loopId);
}
static void test_wants_of_the_client_loop_read_are_taken() {
proto_slot_t &a = connect(1);
TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, takeWants(a, 1));
TEST_ASSERT_EQUAL_UINT32(0, a.wants.load());
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_a_sixth_client_gets_no_slot);
RUN_TEST(test_a_freed_slot_is_taken_again_clean);
RUN_TEST(test_no_sync_before_hello);
RUN_TEST(test_a_new_client_gets_the_schema_unless_it_has_it);
RUN_TEST(test_the_next_frame_waits_for_next);
RUN_TEST(test_next_after_the_sync_has_ended_does_nothing);
RUN_TEST(test_a_sync_again_starts_over_without_the_schema);
RUN_TEST(test_no_next_for_30s_closes_the_client_once);
RUN_TEST(test_a_finished_sync_never_times_out);
RUN_TEST(test_a_client_that_left_mid_sync_is_forgotten);
RUN_TEST(test_the_next_client_in_the_slot_gets_its_own_sync);
RUN_TEST(test_wants_taken_for_a_client_that_left_stay_for_the_next);
RUN_TEST(test_wants_of_the_client_loop_read_are_taken);
return UNITY_END();
}