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

@ -35,6 +35,34 @@ def test_an_event_type_from_a_client_is_unknown(connect, schema):
assert error_name(schema, reply_to(c, msg_id)) == "unknownOp"
def test_next_and_sync_with_an_id_are_answered(connect, schema):
"""A message with an id gets one reply, control messages too."""
c = synced(connect, schema)
msg_id = c.send(schema.type_numbers["next"], with_id=True)
assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1
msg_id = c.send(schema.type_numbers["sync"], with_id=True)
assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1
def test_a_long_message_gets_one_reply(connect, schema):
"""A frame bigger than a TCP segment reaches the device in pieces, which it
can't put together yet: still one reply for the whole message. The message
is wrong either way, so a reply is due however it arrives."""
c = synced(connect, schema)
c.send_text('{"v":2,"t":%d,"id":5,"x":"%s"}' % (schema.type_numbers["shadeState"], "a" * 3000))
replies = []
try:
while True:
f = c.recv(timeout=3)
if f["t"] == 2:
replies.append(f)
except TimeoutError:
pass
assert len(replies) == 1, replies
# In pieces it can't be read, so its id isn't known; whole, it is an event.
assert error_name(schema, replies[0]) in ("badRequest", "unknownOp")
def test_subscribing_to_a_feed_is_ok(connect, schema):
c = synced(connect, schema)
for feed in ("frames", "frequencyScan"):

View file

@ -16,13 +16,17 @@ MAX_CLIENTS = 5
@pytest.fixture
def watch_heap(host):
"""Fails the test when the device rebooted or its heap fell under the floor."""
"""Fails the test when the device rebooted or its heap fell under the floor.
The device reports its lowest heap since it booted, so a dip under the
floor only shows while that low is still above it."""
before = heap(host)
if before["min"] < HEAP_FLOOR:
pytest.skip("the heap has been down to %u bytes since boot: reboot the device to check the floor" % before["min"])
yield before
after = heap(host)
assert after["uptime"] > before["uptime"], "the device rebooted"
assert after["min"] >= HEAP_FLOOR or after["min"] == before["min"], (
"the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR))
assert after["min"] >= HEAP_FLOOR, "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR)
def test_every_client_reconnecting_at_once(connect, schema, watch_heap):