core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии

- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
This commit is contained in:
2026-09-27 10:53:28 +03:00
parent 345fe19ca7
commit e74f3dc67a
15 changed files with 2009 additions and 26 deletions

View File

@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Интеграционные сценарии сессии против mock-модуля (mock_ac.py).
usage: test_session_mock.py <session_runner> <mock_ac.py>
Сценарии: normal, rekey, fail_push, slot_503, no_poll, bad_ke, set_get.
"""
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
RUNNER = sys.argv[1]
MOCK = sys.argv[2]
# Синтетический ключ (НЕ боевой).
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
KEY_ID = 64201
DSN = "AC000W00MOCK0001"
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class Proc:
"""Процесс с построчным логом stdout."""
def __init__(self, argv, env=None):
self.lines = []
self.lock = threading.Lock()
self.ev = threading.Event()
self.proc = subprocess.Popen(
argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True)
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self):
for line in self.proc.stdout:
with self.lock:
self.lines.append(line.rstrip("\n"))
self.ev.set()
def wait_line(self, prefix, timeout, exclude_prefix=None):
"""Ждёт строку, начинающуюся с prefix (или содержащую, если prefix
начинается с '~')."""
contains = prefix.startswith("~")
needle = prefix[1:] if contains else prefix
deadline = time.time() + timeout
seen = 0
while time.time() < deadline:
with self.lock:
for i, line in enumerate(self.lines):
if i < seen:
continue
hit = (needle in line) if contains else line.startswith(prefix)
if hit:
if exclude_prefix and line.startswith(exclude_prefix):
continue
return line
seen = i + 1
self.ev.wait(0.2)
return None
def all_lines(self):
with self.lock:
return list(self.lines)
def stop(self, timeout=10):
self.proc.terminate()
try:
self.proc.wait(timeout)
except subprocess.TimeoutExpired:
self.proc.kill()
def run_mock(extra, port):
p = Proc([sys.executable, MOCK, "--port", str(port), "--lanip-key", LANIP_KEY,
"--key-id", str(KEY_ID)] + extra)
if p.wait_line("READY", 10) is None:
raise RuntimeError("mock не стартовал: " + "\n".join(p.all_lines()))
return p
def run_runner(mock_port, listen_port, props, duration, env_extra=None,
timeout=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen_port), DSN,
LANIP_KEY, str(KEY_ID), str(duration)] + props
return Proc(argv, env=env), (timeout or duration + 25)
FAILURES = []
def check(cond, what):
tag = "ok " if cond else "FAIL"
print(f" [{tag}] {what}")
if not cond:
FAILURES.append(what)
def scenario_normal():
print("== scenario: normal (установка, батч GET, push, delete)")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
runner, timeout = run_runner(mock_port, listen,
["operation_mode", "fan_speed"], 3)
line = runner.wait_line("STATE online", 10)
check(line is not None, "переход в online")
p1 = runner.wait_line("PROP operation_mode", 10)
p2 = runner.wait_line("PROP fan_speed", 10)
check(p1 is not None and "cid=" not in p1 and " i:6" in p1, f"push #1: {p1}")
check(p2 is not None and " i:4" in p2, f"push #2: {p2}")
check(runner.wait_line("DELETED", timeout) is not None, "delete_session")
check(runner.wait_line("STATS", 5) is not None, "STATS")
check("rekeys=1" in " ".join(runner.all_lines()), "ровно 1 key exchange")
check("CMD GET operation_mode" in "\n".join(mock.all_lines()),
"mock исполнил GET")
check("DELETE" in "\n".join(mock.all_lines()), "mock получил DELETE")
runner.stop()
mock.stop()
def scenario_rekey():
print("== scenario: rekey по local_reg (chain непрерывна)")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--rekey-every", "2", "--push-every", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 6,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
t1 = runner.wait_line("PROP tick", 15)
t2 = runner.wait_line("PROP tick", 15)
check(t1 is not None and t2 is not None, "спонтанные push после re-key")
runner.wait_line("DELETED", timeout)
stats = "\n".join(runner.all_lines())
check("rekeys=" in stats and "pushes_bad=0" in stats,
f"без потерянных push: {stats.splitlines()[-1] if stats else '?'}")
check("CMD bad-sign" not in "\n".join(mock.all_lines()),
"mock проверял подписи наших команд")
runner.stop()
mock.stop()
def scenario_fail_push():
print("== scenario: push с битой подписью -> recovering -> online")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--push-every", "1", "--fail-pushes", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 8,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
rec = runner.wait_line("STATE recovering 6", 15)
check(rec is not None, f"recovering после битой подписи: {rec}")
# Следующий push расшифровывается (цепочка в синке) -> online.
check(runner.wait_line("STATE online 0", 15) is not None,
"online восстановлен следующим валидным push")
check(runner.wait_line("STATS", timeout) is not None, "STATS получены")
stats = "\n".join(runner.all_lines())
stats_line = [l for l in stats.splitlines() if l.startswith("STATS")]
check("pushes_bad=1" in stats, f"учтён 1 битый push: {stats_line}")
runner.stop()
mock.stop()
def scenario_slot_503():
print("== scenario: 503 — нет слотов")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--503"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 4,
env_extra={"RUNNER_NOSLOT_RETRY_MS": "2000"})
line = runner.wait_line("STATE offline 1", 10)
check(line is not None, "offline с ошибкой NoSlot")
regs = [l for l in mock.all_lines() if l.startswith("REG")]
check(len(regs) >= 1, "local_reg доходил до модуля")
runner.stop()
mock.stop()
def scenario_no_poll():
print("== scenario: KE без poll — активация не наступила, тихая пауза")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--no-poll"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 5,
env_extra={"RUNNER_ACTIVATION_MS": "1500",
"RUNNER_QUIET_MS": "3000",
"RUNNER_KEEPALIVE_MS": "800"})
line = runner.wait_line("STATE recovering 5", 15)
check(line is not None, "recovering с ActivationTimeout")
time.sleep(1.0)
regs = [l for l in mock.all_lines() if l.startswith("REG")]
# Первый reg + возможно второй до таймаута активации; после — тишина.
check(len(regs) <= 3, f"нет спама local_reg (получено {len(regs)})")
runner.stop()
mock.stop()
def scenario_bad_ke():
print("== scenario: key_exchange ver=2 -> key_error")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--fail-first-ke"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 3)
line = runner.wait_line("STATE key_error 4", 10)
check(line is not None, "key_error с BadKeyExchange")
check(mock.wait_line("~-> 426", 10) is not None, "mock получил 426")
runner.stop()
mock.stop()
def scenario_chain_divergence_heals():
print("== scenario: входящая цепочка расходится на 1 сообщение и сходится")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--push-every", "1", "--garbage-pushes", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 8,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
check(runner.wait_line("STATE recovering 6", 15) is not None,
"recovering после урезанного push")
# CBC-состояние = последний шифроблок: следующий полный push сходится.
check(runner.wait_line("STATE online 0", 15) is not None,
"online восстановлен следующим push (цепочка сошлась)")
stats = runner.wait_line("STATS", timeout)
# Урезанный push + один residual (первый блок следующего) = 2 потери,
# далее цепочка сошлась (состояние CBC = последний шифроблок).
check(stats is not None and "pushes_bad=2" in stats,
f"ровно 2 потери: {stats}")
runner.stop()
mock.stop()
def scenario_outbound_desync_rekey():
print("== scenario: исходящий десинк -> модуль ре-кает на local_reg")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--break-outbound", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, ["operation_mode"], 12,
env_extra={"RUNNER_KEEPALIVE_MS": "1000",
"RUNNER_SET_NAME": "fan_speed",
"RUNNER_SET_VALUE": "2",
"RUNNER_SET_AT": "6"})
check(runner.wait_line("STATE online", 10) is not None, "online")
check(mock.wait_line("~CMD skipped", 10) is not None,
"мок пропустил ответ (десинк)")
check(mock.wait_line("~CMD bad-sign", 15) is not None,
"подпись следующей команды не сошлась")
# Реальный модуль ре-кает на следующем local_reg (как в probe1-3);
# после re-key цепочки свежие — SET доходит.
check(mock.wait_line("~CMD SET fan_speed=2", 20) is not None,
"SET доставлена после re-key")
check(len([l for l in mock.all_lines() if l.startswith("KE ")]) >= 2,
"второй key exchange был")
runner.stop()
mock.stop()
def scenario_set_get():
print("== scenario: SET + подтверждение GET (эха нет)")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
env = {"RUNNER_SET_NAME": "fan_speed", "RUNNER_SET_VALUE": "3",
"RUNNER_SET_AT": "1"}
runner, timeout = run_runner(mock_port, listen, [], 5, env_extra=env)
line = runner.wait_line("SET_DONE fan_speed=3", 10)
check(line is not None, "SET отправлена")
check(mock.wait_line("CMD SET fan_speed=3", 10) is not None,
"mock применил SET")
runner.wait_line("DELETED", timeout)
runner.stop()
mock.stop()
def main():
scenarios = [scenario_normal, scenario_rekey, scenario_fail_push,
scenario_slot_503, scenario_no_poll, scenario_bad_ke,
scenario_chain_divergence_heals,
scenario_outbound_desync_rekey, scenario_set_get]
for sc in scenarios:
sc()
print()
if FAILURES:
print("ПРОВАЛЕНО:", len(FAILURES))
for f in FAILURES:
print(" -", f)
sys.exit(1)
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
if __name__ == "__main__":
main()