From 055ffa27b2b95da1cc2cc1d4667ab422ebcb0568 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:28:50 +0200 Subject: [PATCH] Add offline multipath charts and count timestamped ACKs --- tests/test_etcp_multipath.c | 12 ++- tools/etcp_diag_report.py | 157 ++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tools/etcp_diag_report.py diff --git a/tests/test_etcp_multipath.c b/tests/test_etcp_multipath.c index c4d40405..151e54be 100644 --- a/tests/test_etcp_multipath.c +++ b/tests/test_etcp_multipath.c @@ -292,6 +292,14 @@ void __wrap_bbr_main(struct bbr* model, const struct bbr_rate_sample* sample, ui } } +/* Обычный ACK-only может нести timestamp; запрос till и секции измерительной серии относятся к control. */ +static int diagnostic_ack_only(const struct ETCP_DGRAM* pkt, unsigned ack_size) { + if (pkt->payload_len || pkt->burst_packet) return 0; + for (unsigned off = ack_size; off < pkt->data_len; off += 5) + if (pkt->data_len - off < 5 || pkt->data[off] != ETCP_SECTION_TIMESTAMP) return 0; + return 1; +} + /* Статистика TX снимается только после принятия send; RX — перед разбором уже расшифрованного пакета. */ static void observe_packet(struct ETCP_DGRAM* pkt, struct ETCP_LINK* link, int sending) { int side = link->etcp->instance == ep[0].inst ? 0 : 1, path = -1; @@ -305,11 +313,11 @@ static void observe_packet(struct ETCP_DGRAM* pkt, struct ETCP_LINK* link, int s CHECK(remaining >= ETCP_ACK_BASE_SIZE); len = etcp_ack_section_size(data[1]); CHECK(len <= remaining); struct diagnostic_interval* d = sending ? diagnostic_for(link) : NULL; if (d) { - int ack_only = !pkt->payload_len && len == pkt->data_len; + int ack_only = diagnostic_ack_only(pkt, len); if (pkt->payload_len && data[1]) { d->piggy_packets++; d->piggy_bytes += len; } else if (ack_only) { d->ack_only_packets++; - d->ack_only_wire += len + ETCP_ENCRYPTED_HDR_SIZE + ETCP_SESSION_HEADER_SIZE + + d->ack_only_wire += pkt->data_len + ETCP_ENCRYPTED_HDR_SIZE + ETCP_SESSION_HEADER_SIZE + SC_NONCE_SIZE + SC_CRC32_SIZE + SC_TAG_SIZE; } else if (!pkt->payload_len) d->control_packets++; for (unsigned n = 0; n < data[1]; n++) { diff --git a/tools/etcp_diag_report.py b/tools/etcp_diag_report.py new file mode 100644 index 00000000..7e246b6e --- /dev/null +++ b/tools/etcp_diag_report.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Проверить CSV UTUN_MULTIPATH_DIAG и построить автономный HTML с графиками и исходными срезами.""" +import argparse +import csv +import json +from pathlib import Path + + +def read_samples(path): + """Проверить схему, учёт очередей и интервалы; неправильный CSV не превращать в правдоподобный график.""" + with path.open(newline="", encoding="utf-8") as source: + reader = csv.DictReader(source) + if not reader.fieldnames or len(set(reader.fieldnames)) != len(reader.fieldnames): + raise ValueError("отсутствуют или повторяются заголовки CSV") + rows = [] + previous = {} + for line, raw in enumerate(reader, 2): + if None in raw or any(value is None for value in raw.values()): + raise ValueError(f"строка {line}: число значений не соответствует заголовку") + row = {key: value if key in ("case", "mask", "phase") else int(value) for key, value in raw.items()} + key = (row["case"], row["mask"], row["side"], row["path"]) + valid = row["dt_us"] > 0 and row["unacked_bytes"] == row["send_bytes"] + row["wait_ack_bytes"] + valid &= row["send_count"] == row["send_fresh"] + row["send_retries"] + valid &= row["flight_min"] <= row["flight_bytes"] <= row["flight_max"] + valid &= row["cwnd_min"] <= row["cwnd_bytes"] <= row["cwnd_max"] + valid &= row["early_ordinary_ack_entries"] <= row["ack_only_entries"] <= row["ack_entries"] + valid &= row["retries"] <= row["data_packets"] and row["rtt_samples"] <= row["bbr_samples"] + if key in previous: + valid &= row["time_us"] - previous[key] == row["dt_us"] + if not valid: + raise ValueError(f"строка {line}: нарушен учёт очередей, счётчиков или времени") + previous[key] = row["time_us"] + rows.append(row) + if not rows: + raise ValueError("CSV не содержит срезов") + return rows + + +PAGE = r''' +ETCP: очереди и pacing + +

ETCP: динамика очередей, DATA и ACK

+

Источник: . Данные проверены. Наведите курсор на график для просмотра всех полей среза.

+ + + + +

Очереди приложения/normalizer/send/wait_ack общие для соединения. Flight, pacer, ACK и канал — для выбранного пути. +Блокировки — число проверок выбора, а не время ожидания. Оранжевый фон: BBR PROBE_RTT. +Пики очереди и задержки эмулятора накоплены с последнего сброса его статистики.

+

Исходный срез

+''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("csv", type=Path) + parser.add_argument("html", type=Path) + args = parser.parse_args() + try: + rows = read_samples(args.csv) + page = PAGE.replace("__ROWS__", json.dumps(rows, ensure_ascii=False).replace("<", "\\u003c")) + page = page.replace("__SOURCE__", json.dumps(args.csv.name, ensure_ascii=False).replace("<", "\\u003c")) + args.html.write_text(page, encoding="utf-8") + except (OSError, ValueError, KeyError) as error: + parser.exit(1, f"Ошибка отчёта ETCP: {error}\n") + print(f"Проверено {len(rows)} срезов; отчёт: {args.html}") + + +if __name__ == "__main__": + main()