From ba31c1471983a2b2bac91f9c9f2a2a539120c070 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 00:57:34 +0200 Subject: [PATCH] fix(gui): say that zero loss runs can also mean 100% loss The "Loss runs" tooltip said a zero with a run length set means the session was too short, and only a later sentence added 100% loss, where the counter stays at zero however long the session runs. It is now one either/or statement in en, pl and zh, matching the README row. The shipped mobile-lte-to-3g test now walks the steps around the outage on one engine (45, 60, 68, 60 and 68 s) through apply_settings, with packets paced under the scenario's speed caps so every drop is a loss drop. The outage drops everything both times, and the steps around it deliver their own loss in runs again (7.5, 6.3 and 6.4% measured). A new mutation entry proves those assertions can fail. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- lang/en.json | 2 +- lang/pl.json | 2 +- lang/zh.json | 2 +- tests/test_burst_loss.py | 50 +++++++++++++++++++++++++++------ tests/test_mutation_registry.py | 10 +++++++ 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ecfaf3..e0bfbd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol let 11 to 33% of packets through, including during the full outage in the `mobile-lte-to-3g` scenario, and the log and the summary described runs of loss that did not exist. A saved "Reproduce:" command with this combination now repeats a run - that loses every packet. + that loses every packet. The tooltip of the "Loss runs" counter says that a zero can + also mean 100% loss. - **With Asymmetry on, the upload's runs of loss are described too.** The log now says when the upload loss cannot reach the number you set in runs that short, and how far diff --git a/lang/en.json b/lang/en.json index d06a3e1..fb58498 100644 --- a/lang/en.json +++ b/lang/en.json @@ -592,7 +592,7 @@ "tips.stat_lan": "Packets to/from the internet dropped in LAN mode.", "tips.stat_local": "Packets to/from the local network dropped by \"Internet only\".", "tips.stat_loss": "Packets dropped because of the configured Loss. Link outages are counted separately, under Link outage.", - "tips.stat_loss_runs": "How many runs of lost packets this session produced. Zero with \"Losses in a row\" set means the session was too short to see one, not that nothing was configured. At 100% loss every packet is lost, so there are no separate runs to count.", + "tips.stat_loss_runs": "How many runs of lost packets this session produced. Zero with \"Losses in a row\" set does not mean nothing was configured: either the session was too short to see a run, or the loss is 100%, so every packet is lost and there are no separate runs to count.", "tips.stat_mtu": "Packets dropped as too large (MTU black hole).", "tips.stat_nat": "Packets dropped after the NAT mapping expired.", "tips.stat_overflow": "Packets dropped because the queue overflowed (heavy overload). This counter always covers ALL captured traffic, even when the view is narrowed to the target: these are packets the TOOL lost, and hiding the ones outside your target would hide its own damage.", diff --git a/lang/pl.json b/lang/pl.json index 1fbba3d..45b6675 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -592,7 +592,7 @@ "tips.stat_lan": "Pakiety do/od internetu odrzucone w trybie LAN.", "tips.stat_local": "Pakiety do/od sieci lokalnej odrzucone przez „Tylko internet”.", "tips.stat_loss": "Pakiety porzucone z powodu ustawionej Utraty. Przerwy w łączu mają własny licznik - Przerwa w łączu.", - "tips.stat_loss_runs": "Ile serii gubionych pakietów wyszło w tej sesji. Zero przy ustawionym polu \"Straty pod rząd\" znaczy, że sesja była za krótka, żeby zobaczyć choć jedną, a nie że nic nie było ustawione. Przy stracie 100% ginie każdy pakiet, więc nie ma osobnych serii do policzenia.", + "tips.stat_loss_runs": "Ile serii gubionych pakietów wyszło w tej sesji. Zero przy ustawionym polu \"Straty pod rząd\" nie znaczy, że nic nie było ustawione: albo sesja była za krótka, żeby zobaczyć choć jedną serię, albo strata wynosi 100%, więc ginie każdy pakiet i nie ma osobnych serii do policzenia.", "tips.stat_mtu": "Pakiety odrzucone jako za duże (czarna dziura MTU).", "tips.stat_nat": "Pakiety odrzucone po wygaśnięciu mapowania NAT.", "tips.stat_overflow": "Pakiety porzucone, bo kolejka się przepełniła (silne przeciążenie). Ten licznik zawsze obejmuje CAŁY przechwycony ruch, nawet gdy widok jest zawężony do celu: to są pakiety zgubione przez NARZĘDZIE, a ukrycie tych spoza celu ukryłoby jego własne szkody.", diff --git a/lang/zh.json b/lang/zh.json index 19880c1..0289a9f 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -592,7 +592,7 @@ "tips.stat_lan": "在局域网模式下,被丢弃的互联网数据包。", "tips.stat_local": "因“仅互联网”模式而被丢弃的本地网络数据包。", "tips.stat_loss": "因配置的“丢包”效果而被丢弃的数据包。链路中断会在“链路中断”中单独统计。", - "tips.stat_loss_runs": "本次会话产生了多少串连续丢包。如果设置了\"连续丢包\"却显示 0,说明会话太短,还没有出现一串,而不是没有设置。丢包率为 100% 时每个数据包都会丢失,因此没有可单独计数的丢包串。", + "tips.stat_loss_runs": "本次会话产生了多少串连续丢包。如果设置了\"连续丢包\"却显示 0,并不表示没有设置:要么会话太短,还没有出现一串,要么丢包率为 100%,每个数据包都会丢失,因此没有可单独计数的丢包串。", "tips.stat_mtu": "因超过 MTU(MTU 黑洞)而被丢弃的数据包。", "tips.stat_nat": "NAT 映射过期后被丢弃的数据包。", "tips.stat_overflow": "因队列溢出(严重过载)而被丢弃的数据包。即使视图已收窄到目标,此计数器也始终覆盖所有已捕获流量,因为这些数据包是本工具丢失的。隐藏目标之外的部分会掩盖工具自身造成的损害。", diff --git a/tests/test_burst_loss.py b/tests/test_burst_loss.py index ed9cbcb..8287467 100644 --- a/tests/test_burst_loss.py +++ b/tests/test_burst_loss.py @@ -491,14 +491,33 @@ def test_total_loss_makes_one_draw_per_packet_like_the_chain_did(): rng.getstate() == expected.getstate(), "(the draw count moved)") +def _paced_drops(core, rng, start, packets=20000): + """Both directions in turn, 0.1 s apart; returns (dropped, clock after). + + Slow on purpose: the scenario below also caps the speed (32 KB/s up at its + slowest), and 1200 bytes every 0.2 s per direction stays under every cap it + sets - so each drop counted here is a LOSS drop, not the rate limiter's. + """ + dropped = 0 + for i in range(packets): + now = start + i * 0.1 + dropped += core.decide(1200, bool(i % 2), 5000, now, rng, remote_ip="1.2.3.4", + remote_port=443, is_tcp=True).drop + return dropped, start + packets * 0.1 + + def test_the_shipped_lte_to_3g_outage_loses_everything(): """The case the review found in a file this project ships. ``mobile-lte-to-3g.json`` cuts the link at 60 s with ``"loss": 100`` and inherits a run length of 8 from the step before, so its full outage let about one packet in nine through - enough for a connection to live through it. - Driven the way a session drives it: scenario step, ``apply_settings``, engine, - core. + + Driven the way a session drives it: every step applied in turn to ONE engine + through ``apply_settings``, the outage entered and left twice, because the + chain state carried from step to step is exactly what a single step on a fresh + core cannot show. Before the outage and after it the loss must be the step's + own number again, arriving in runs. """ from beantester.engine import BeanEngine from beantester.scenario import load_scenario_file @@ -506,16 +525,29 @@ def test_the_shipped_lte_to_3g_outage_loses_everything(): scenario = load_scenario_file(os.path.join(ROOT, "scenarios", "mobile-lte-to-3g.json")) - settings = scenario.settings_at(60.05, DEFAULT_SETTINGS) + outage = scenario.settings_at(60.05, DEFAULT_SETTINGS) check("the step is still total loss with an inherited run length", - settings["loss"] == 100 and settings["loss_burst"] > 1, - f"(loss={settings['loss']}, run={settings['loss_burst']})") + outage["loss"] == 100 and outage["loss_burst"] > 1, + f"(loss={outage['loss']}, run={outage['loss_burst']})") engine = BeanEngine() - apply_settings(engine, settings) engine.core.reset_buckets(0.0) - dropped, _ = _drops(engine.core, packets=20000, alternate=True) - check("the outage drops every packet", dropped == 20000, - f"(dropped {dropped} of 20000)") + rng = random.Random(21) + clock = 0.0 + for at in (45.05, 60.05, 68.05, 60.05, 68.05): + settings = scenario.settings_at(at, DEFAULT_SETTINGS) + apply_settings(engine, settings) + runs_before = engine.core.loss_bursts + dropped, clock = _paced_drops(engine.core, rng, clock) + share = 100.0 * dropped / 20000 + if settings["loss"] >= 100: + check(f"at {at} s the outage drops every packet", dropped == 20000, + f"(dropped {dropped} of 20000)") + continue + check(f"at {at} s the loss is the step's own {settings['loss']}% again", + abs(share - settings["loss"]) <= 2.0, f"(delivered {share:.2f}%)") + check(f"at {at} s it arrives in runs again", + engine.core.loss_bursts > runs_before, + f"({engine.core.loss_bursts - runs_before} runs started)") def _burst_lines(monkeypatch, **fields): diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 404ddf8..dec532c 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -2397,6 +2397,16 @@ " return (1.0, r, 1.0)", "test": "test_total_loss_loses_every_packet_whatever_the_run_length", }, + { + # The shipped scenario walked step by step on one engine: the steps around + # its outage must deliver their own loss, so a chain derived wrongly shows + # up there even though the outage itself still drops everything. + "label": "burst loss: the steps around a scenario's outage deliver the wrong loss", + "file": "beantester/core.py", + "old": " p = loss * r / room", + "new": " p = r", + "test": "test_the_shipped_lte_to_3g_outage_loses_everything", + }, { # The upload walks its own chain from its own loss, so it clamps on its # own - and until P3-4 the apply log never asked about it.