From 60eac4eed28f3dc755cb0a4b2e2763e1e856a74a Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 00:37:43 +0200 Subject: [PATCH] fix(core): lose every packet at 100% loss with a run length set burst_loss_params returned (1.0, r, 1.0) for total loss: a two-state chain meant to stay in the bad state, which still leaves it with probability r and lets that packet through. 100% loss in runs of 2, 4, 6 and 8 delivered 66.6, 79.9, 85.6 and 88.9%, while "achievable" said 100%, so neither the apply log nor the summary strip noticed. The shipped mobile-lte-to-3g scenario hits it at 60 s, where "loss": 100 inherits a run length of 8. Total loss now takes the independent draw: every packet is dropped with the same single draw per packet, so a seed replays the same way, and nothing below 100% changes. The log and the strip ask the same function, so they stop describing runs that do not exist. The run counter stays at 0 at 100%, and its tooltip and the README now say so. With asymmetry on, the upload walks its own chain from its own loss, but only the download was described: an upload loss the runs could not carry was clamped in silence, and the strip showed the upload loss without its run length. Both directions are now described, the upload only while the switch is on. The test that asserted the old tuple is rewritten on purpose. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++ README.md | 5 +- beantester/core.py | 15 ++- beantester/settings.py | 34 +++++- beantester/summary.py | 46 +++++-- lang/en.json | 4 +- lang/pl.json | 4 +- lang/zh.json | 4 +- tests/test_burst_loss.py | 204 ++++++++++++++++++++++++++++++-- tests/test_mutation_registry.py | 61 ++++++++++ 10 files changed, 359 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78ff671..9ecfaf39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Fixed +- **100% loss with "Losses in a row" set now loses every packet.** A run length used to + 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. + +- **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 + apart its runs will be, and the summary shows the upload's run length. Before, only + the download was described. + - **Patterns with a repeat inside a repeat are refused as too slow.** Patterns such as `re:^(\w+)+$` used to pass the speed check, then took seconds on a single name or address and could stall the network. They are now refused when you type them. A diff --git a/README.md b/README.md index ba12dc65..d369a00f 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,8 @@ Two limits worth knowing, and the log states both when you apply the settings. A cannot arrive in very short runs, because runs that short leave too little room between them, so the run says what it will really deliver. And a long run length puts the runs far apart, so a short session may not see one at all. Each direction gets its own runs, so a run of twenty means twenty -in a row in that direction. +in a row in that direction, and with Asymmetry on the log states both limits for the upload too. +At 100% loss every packet is lost, so the run length changes nothing. **Link flapping** - cyclic total loss of traffic: every *Period* seconds the link is dead for the given percentage of the time. Simulates a flickering connection. This is **not** the same as losses @@ -1029,7 +1030,7 @@ what `packets_seen` counted in the first place - so every row records it in `cap | `packets_seen` | packets captured | | `packets_in_scope` | of those, the ones targeting selected for impairment | | `dropped_loss` | dropped by the Loss setting | -| `loss_runs` | how many RUNS that loss arrived in (see "Losses in a row"). 0 with a run length set means the session was too short to see one | +| `loss_runs` | how many RUNS that loss arrived in (see "Losses in a row"). 0 with a run length set means the session was too short to see one, or the loss is 100% and there are no separate runs | | `dropped_overflow` | dropped because the tool's own queue was full (see the note on it below) | | `corrupted` | packets whose payload was flipped | | `duplicated` | extra copies queued | diff --git a/beantester/core.py b/beantester/core.py index a92478f5..4293c553 100644 --- a/beantester/core.py +++ b/beantester/core.py @@ -43,7 +43,8 @@ def burst_loss_params(loss, mean_burst): Returns ``(p, r, achievable)`` - the good-to-bad and bad-to-good transition probabilities, plus the loss fraction that pair actually delivers - or ``None`` when the loss should stay INDEPENDENT, which is the behaviour that - predates this function and the one every default still takes. + predates this function and the one every default still takes. Total loss is + one of those: every packet goes, so there is no run length to deliver. The model is Gilbert's two-state burst-noise channel (Gilbert 1960, extended by Elliott 1963), the same one ``tc netem`` offers as ``loss gemodel``. It is @@ -100,10 +101,14 @@ def burst_loss_params(loss, mean_burst): r = 1.0 / mean_burst room = 1.0 - loss if room <= 0.0: - # Total loss. Every packet goes whatever the chain says, so the chain may - # as well stay bad - and this branch is what keeps the division below - # from raising on exactly this input. - return (1.0, r, 1.0) + # Total loss has no runs to shape: there is no gap for one to end in. The + # chain cannot say that - with r > 0 it leaves the bad state, and the packet + # it leaves on goes THROUGH. Measured before this was fixed: 100% asked for + # in runs of 2, 4, 6 and 8 delivered 66.6, 79.9, 85.6 and 88.9% (external + # review, P1-1). The independent draw at 1.0 drops every packet with the + # same ONE draw per packet the chain makes, so a seed replays the same + # way. This branch is also what keeps the division below from raising. + return None p = loss * r / room if p >= 1.0: # More loss than runs this short can carry. The good state then lasts a diff --git a/beantester/settings.py b/beantester/settings.py index 227641c5..66c70695 100644 --- a/beantester/settings.py +++ b/beantester/settings.py @@ -525,7 +525,13 @@ def _destination_is_frozen(engine, dst_ip, dst_port): or str(getattr(core, "dst_port", "")) != str(dst_port)) -def _say_what_the_burst_loss_will_do(loss_pct, mean_burst, log): +# The clamp line and the gap line, once per direction that has a loss of its own: +# the download (both ways with asymmetry off), then the upload. +_BURST_LINES_DOWN = ("log.loss_burst_clamped", "log.loss_burst_gap") +_BURST_LINES_UP = ("log.loss_burst_clamped_up", "log.loss_burst_gap_up") + + +def _say_what_the_burst_loss_will_do(g, log): """Two things a person cannot read off the two fields in front of them. Said at APPLY time, before the run, because both of them are the difference @@ -543,20 +549,40 @@ def _say_what_the_burst_loss_will_do(loss_pct, mean_burst, log): look reasonable and nothing happens, which reads exactly like a broken tool. So the run length AND the expected distance between runs are said out loud, in packets, which is the unit the field is in. + + Total loss says neither: every packet goes, so there are no runs to describe + (``burst_loss_params`` answers None), the same silence as a run length with no + loss at all. + + Per DIRECTION, which is the half that was missing (external review, P3-4). + The run length is one field (ADR 2026-09-01), but each direction walks its own + chain derived from its own loss, so an upload loss the runs cannot carry is + clamped exactly like a download one - and was, in silence. The upload is said + only with asymmetry on: off, its values are not read at all, and a line about + them would describe a link this session is not producing. A function of its + own so ``apply_settings`` gains no branch. """ + _say_burst_loss_for(g("loss"), g("loss_burst"), _BURST_LINES_DOWN, log) + if g("asym"): + _say_burst_loss_for(g("loss_up"), g("loss_burst"), _BURST_LINES_UP, log) + + +def _say_burst_loss_for(loss_pct, mean_burst, keys, log): + """One direction's two lines; ``keys`` names its clamp line and its gap line.""" loss = to_number(loss_pct) / 100.0 params = burst_loss_params(loss, to_number(mean_burst)) if params is None: return + clamped_key, gap_key = keys _p, _r, achievable = params if achievable < loss: - log(T("log.loss_burst_clamped", burst=number_string(mean_burst), + log(T(clamped_key, burst=number_string(mean_burst), asked=number_string(loss_pct), delivered=number_string(round(achievable * 100.0, 2)))) # Packets per cycle: one run of `mean_burst` for every `mean_burst/achievable` # packets that go past. Rounded to whole packets - the field is in packets and # a fractional one would read as precision this cannot have. - log(T("log.loss_burst_gap", burst=number_string(mean_burst), + log(T(gap_key, burst=number_string(mean_burst), gap=number_string(round(to_number(mean_burst) / achievable)))) @@ -595,7 +621,7 @@ def apply_settings(engine, s, log=lambda *_: None): # assignments and nothing else. The log lines come out in the order they # always did - only their interleaving with the setters is gone, and setters # do not log. - _say_what_the_burst_loss_will_do(g("loss"), g("loss_burst"), log) + _say_what_the_burst_loss_will_do(g, log) dst_ip = setting_expression("dst_ip", g("dst_ip")) dst_port = setting_expression("dst_port", g("dst_port")) # With the driver filter narrowed, the destination fields are START-ONLY, and diff --git a/beantester/summary.py b/beantester/summary.py index 41d4b98c..0679869a 100644 --- a/beantester/summary.py +++ b/beantester/summary.py @@ -5,6 +5,30 @@ from .utils import number_string, to_number +def _plain_parts(g, tr, num, pairs): + """One phrase per ``(key, phrase)`` pair whose value is not zero.""" + return [tr(phrase, v=num(key)) for key, phrase in pairs if to_number(g(key))] + + +def _loss_parts(g, tr, num, key): + """The upload's loss, with its run length right after it. + + The same question ``settings_summary`` asks inline for the download (it says + there why it is not shared): the run length is one field, but whether it + SHAPES a direction depends on that direction's loss. It is asked of the + function that decides it rather than compared against a threshold here, so + the strip cannot claim runs the engine is not producing - at 100% loss, for + one, there are none. + """ + if not to_number(g(key)): + return [] + parts = [tr("summary.loss", v=num(key))] + if burst_loss_params(to_number(g(key)) / 100.0, + to_number(g("loss_burst"))) is not None: + parts.append(tr("summary.loss_burst", v=num("loss_burst"))) + return parts + + def _upload_parts(g, tr, num): """The upload half of the description, or nothing when the link is symmetric. @@ -15,17 +39,18 @@ def _upload_parts(g, tr, num): An asymmetric run with every upload value at zero still says so. Silence there would be the misleading answer: the reader would take the numbers above to apply in both directions, which is exactly what they no longer do. + + In the download half's order, run length included: until the external review + (P3-4) the upload loss was said without it, so an upload losing in runs read + exactly like one losing evenly. """ if not g("asym"): return [] - inner = [] - for key, phrase in (("latency_up", "summary.latency"), - ("jitter_up", "summary.jitter"), - ("loss_up", "summary.loss"), - ("corrupt_up", "summary.corrupt"), - ("dup_up", "summary.dup")): - if to_number(g(key)): - inner.append(tr(phrase, v=num(key))) + inner = (_plain_parts(g, tr, num, (("latency_up", "summary.latency"), + ("jitter_up", "summary.jitter"))) + + _loss_parts(g, tr, num, "loss_up") + + _plain_parts(g, tr, num, (("corrupt_up", "summary.corrupt"), + ("dup_up", "summary.dup")))) if to_number(g("spike_prob_up")) and to_number(g("spike_ms_up")): inner.append(tr("summary.spikes", ms=num("spike_ms_up"), p=num("spike_prob_up"))) @@ -57,6 +82,11 @@ def settings_summary(s, lang=None, prefix_key="summary.prefix"): # Same loss figure, very different link: the run length is asked of the # function that DECIDES it rather than compared against a threshold here, # so the strip cannot claim runs the engine is not producing. + # 🔴 Inline, not _loss_parts, and that is about the complexity ratchet, not + # taste: this function IS `max-complexity` in pyproject.toml. Moving these + # two branches out lowers the ceiling onto `decide` - which leaves the + # "add an impairment" recipe no room - and doubles COMPLEX_NEAR_CEILING + # (measured 2026-09-29: 4 -> 8). Both are the owner's call, not a tidy-up's. if burst_loss_params(to_number(g("loss")) / 100.0, to_number(g("loss_burst"))) is not None: parts.append(tr("summary.loss_burst", v=num("loss_burst"))) diff --git a/lang/en.json b/lang/en.json index 10fc2bd4..d06a3e1e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -303,7 +303,9 @@ "log.loaded_profile": "Loaded profile", "log.loop": "loop", "log.loss_burst_clamped": "Loss cannot reach {asked}% in runs of {burst} packets, so this session will lose {delivered}%.", + "log.loss_burst_clamped_up": "Upload loss cannot reach {asked}% in runs of {burst} packets, so uploads in this session will lose {delivered}%.", "log.loss_burst_gap": "Loss will arrive in runs of about {burst} packets, roughly one run every {gap} packets.", + "log.loss_burst_gap_up": "Upload loss will arrive in runs of about {burst} packets, roughly one run every {gap} packets.", "log.marker_needs_start": "Bug marker works after start (START).", "log.narrow_applied": "Capturing only the targeted traffic - the driver hands over your destination's traffic and nothing else, so the counters and the connection list cover that traffic only.", "log.narrow_no_effect": "\"Capture only the targeted traffic\" had no effect - this destination cannot be turned into a driver filter (a wildcard, an re: pattern, or no destination at all). Capturing everything, as usual.", @@ -590,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.", + "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_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 b4f5da87..1fbba3d7 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -303,7 +303,9 @@ "log.loaded_profile": "Wczytano profil", "log.loop": "pętla", "log.loss_burst_clamped": "Strata nie osiągnie {asked}% przy seriach po {burst} pakietów, więc ta sesja zgubi {delivered}%.", + "log.loss_burst_clamped_up": "Strata przy wysyłaniu nie osiągnie {asked}% w seriach po {burst} pakietów, więc wysyłanie w tej sesji zgubi {delivered}%.", "log.loss_burst_gap": "Strata będzie przychodzić seriami po około {burst} pakietów, mniej więcej jedna seria na {gap} pakietów.", + "log.loss_burst_gap_up": "Strata przy wysyłaniu będzie przychodzić seriami po około {burst} pakietów, mniej więcej jedna seria na {gap} pakietów.", "log.marker_needs_start": "Znacznik błędu zadziała po uruchomieniu (START).", "log.narrow_applied": "Przechwytywanie tylko ruchu celu - sterownik podaje ruch z Twoim celem i nic więcej, więc liczniki i lista połączeń obejmują wyłącznie ten ruch.", "log.narrow_no_effect": "„Przechwytuj tylko ruch celu” nic nie dało - tego celu nie da się zamienić na filtr sterownika (wildcard, wzorzec re: albo brak celu). Przechwytywane jest wszystko, jak zwykle.", @@ -590,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.", + "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_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 69273b82..19880c19 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -303,7 +303,9 @@ "log.loaded_profile": "已加载配置方案", "log.loop": "循环", "log.loss_burst_clamped": "在每串 {burst} 个数据包的情况下,丢包率无法达到 {asked}%,因此本次会话将丢失 {delivered}%。", + "log.loss_burst_clamped_up": "在每串 {burst} 个数据包的情况下,上传丢包率无法达到 {asked}%,因此本次会话的上传将丢失 {delivered}%。", "log.loss_burst_gap": "丢包将以每串约 {burst} 个数据包的方式出现,大约每 {gap} 个数据包出现一串。", + "log.loss_burst_gap_up": "上传丢包将以每串约 {burst} 个数据包的方式出现,大约每 {gap} 个数据包出现一串。", "log.marker_needs_start": "启动会话后才能添加故障标记。", "log.narrow_applied": "当前仅捕获目标流量:驱动只会把指定目标地址的流量交给本工具,因此计数器和连接列表也只覆盖这些流量。", "log.narrow_no_effect": "“仅捕获目标流量”未生效:此目标地址无法转换为驱动过滤器(使用了通配符、re: 正则表达式,或根本未设置目标地址)。将照常捕获全部流量。", @@ -590,7 +592,7 @@ "tips.stat_lan": "在局域网模式下,被丢弃的互联网数据包。", "tips.stat_local": "因“仅互联网”模式而被丢弃的本地网络数据包。", "tips.stat_loss": "因配置的“丢包”效果而被丢弃的数据包。链路中断会在“链路中断”中单独统计。", - "tips.stat_loss_runs": "本次会话产生了多少串连续丢包。如果设置了\"连续丢包\"却显示 0,说明会话太短,还没有出现一串,而不是没有设置。", + "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 7dea9d57..ed9cbcb0 100644 --- a/tests/test_burst_loss.py +++ b/tests/test_burst_loss.py @@ -20,12 +20,16 @@ deliver half. * **the impossible corner is loud, not quiet.** Some loss/length pairs cannot exist. They are clamped, and ``achievable`` reports what the pair really does. +* **total loss is total.** 100% loses every packet whatever the run length says, + in both directions - and the log and the summary strip, which ask the same + function, stop describing runs that do not exist. * **the chain is re-derived from BOTH of its inputs.** ``p`` depends on the loss as well as the run length, so changing only the loss has to move it. """ +import os import random -from fakes import check +from fakes import ROOT, check from beantester.core import BeanCore, burst_loss_params @@ -114,12 +118,25 @@ def test_a_pair_that_cannot_exist_is_clamped_and_says_so(): f"(achievable={achievable})") -def test_total_loss_does_not_divide_by_zero(): - """``1 - loss`` is the denominator, so 100% is the input that would raise.""" - p, r, achievable = burst_loss_params(1.0, 5.0) - check("total loss keeps the chain in the bad state", p == 1.0, f"(p={p})") - check("and reports total loss", achievable == 1.0, f"(achievable={achievable})") - check("r is still the run length", abs(r - 0.2) < 1e-12, f"(r={r})") +def test_total_loss_is_left_to_the_independent_draw(): + """Total loss has no runs to shape, so it takes the plain draw. + + This test used to assert the opposite - ``(1.0, r, 1.0)``, a chain meant to + "stay bad" - and so pinned the bug the external review found (P1-1): with + ``r > 0`` the chain LEAVES the bad state, and the packet it leaves on goes + through. 100% asked for in runs of 2, 4, 6 and 8 delivered 66.6, 79.9, 85.6 + and 88.9%, while ``achievable`` said 100. ``1 - loss`` is still the + denominator, so this is also still the input that would raise. + """ + for burst in (1.5, 2.0, 5.0, 1000.0): + check(f"total loss in runs of {burst} is the independent draw", + burst_loss_params(1.0, burst) is None, + f"(got {burst_loss_params(1.0, burst)})") + check("and so is anything past total", burst_loss_params(1.5, 5.0) is None, + f"(got {burst_loss_params(1.5, 5.0)})") + check("while just under total is still a chain, clamped out loud", + burst_loss_params(0.9999, 5.0) == (1.0, 0.2, 1.0 / 1.2), + f"(got {burst_loss_params(0.9999, 5.0)})") # --------------------------------------------------------------------------- # @@ -414,3 +431,176 @@ def test_raising_the_upload_loss_does_not_cut_a_download_run_in_flight(): check("while the download keeps its own", abs(_chain(core, False).burst_p - burst_loss_params(0.5, 50.0)[0]) < 1e-12, f"(p={_chain(core, False).burst_p})") + + +# --------------------------------------------------------------------------- # +# Total loss, and the surfaces that ASK the arithmetic about it. The answer is +# per direction and three places read it - the packet path, the apply-time log +# and the summary strip - so each one is driven in both directions: a value that +# depends on its context and has several readers is guarded at the readers, not +# at one of them in one variant. +# --------------------------------------------------------------------------- # +def _upload_core(loss_up, burst): + """A core that loses only on the way UP, through the asymmetry switch.""" + core = BeanCore() + core.set_params(0, 0, 0, 0, 0, 0, 0) + core.set_loss_burst(burst) + core.set_asymmetry(True, loss_up, 0, 0, 0, 0, 0, 0) + core.reset_buckets(0.0) + return core + + +def test_total_loss_loses_every_packet_whatever_the_run_length(): + """Every packet, both ways, and the upload through its own chain. + + Before the fix a run length let 11 to 33% of a "100%" through (external + review, P1-1), and the run counter counted thousands of runs in a session + that has none. + """ + for burst in (2, 6, 8, 1000): + core = _core(loss=100, burst=burst) + dropped, _ = _drops(core, packets=20000, alternate=True) + check(f"100% in runs of {burst} drops every packet in both directions", + dropped == 20000, f"(dropped {dropped} of 20000)") + check(f"100% in runs of {burst} starts no runs, because there are none", + core.loss_bursts == 0, f"({core.loss_bursts})") + dropped, _ = _drops(_upload_core(100, 6), packets=20000) + check("100% UPLOAD loss in runs of 6 drops every upload packet", + dropped == 20000, f"(dropped {dropped} of 20000)") + + +def test_total_loss_makes_one_draw_per_packet_like_the_chain_did(): + """The seed discipline the chain was built around, kept at 100%. + + Step 8 makes ONE draw per packet, independent or in runs, and a stored + ``Reproduce:`` command depends on that count. At 100% nothing after step 8 is + reached, so the generator has to stand exactly where N plain draws leave it. + A shortcut that skipped the draw because "every packet goes anyway" would + shift every draw after it - a scenario stepping back down from 100% would + then replay into a different session. + """ + core = _core(loss=100, burst=6) + rng = random.Random(9) + for i in range(2000): + core.decide(1200, bool(i % 2), 5000, i * 0.001, rng, + remote_ip="1.2.3.4", remote_port=443, is_tcp=True) + expected = random.Random(9) + for _ in range(2000): + expected.random() + check("2000 packets at 100% in runs of 6 made exactly 2000 draws", + rng.getstate() == expected.getstate(), "(the draw count moved)") + + +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. + """ + from beantester.engine import BeanEngine + from beantester.scenario import load_scenario_file + from beantester.settings import DEFAULT_SETTINGS, apply_settings + + scenario = load_scenario_file(os.path.join(ROOT, "scenarios", + "mobile-lte-to-3g.json")) + settings = 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']})") + 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)") + + +def _burst_lines(monkeypatch, **fields): + """What an apply says about runs of loss, as ``(key, values)`` pairs. + + ``T`` is swapped for a recorder, so the assertions name the MESSAGE and its + numbers rather than one translation of them - the language the suite happens + to run in is not the question here. + """ + import beantester.settings as settings_mod + from beantester.engine import BeanEngine + monkeypatch.setattr(settings_mod, "T", lambda key, **values: (key, values)) + said = [] + settings_mod.apply_settings(BeanEngine(), + dict(settings_mod.DEFAULT_SETTINGS, **fields), + log=said.append) + return [line for line in said + if isinstance(line, tuple) and line[0].startswith("log.loss_burst")] + + +def test_total_loss_says_nothing_about_runs(monkeypatch): + """At 100% the log used to promise "runs of about 6, one run every 6 packets". + + A run length at total loss is as inert as one at no loss, and gets the same + silence, in either direction. + """ + down = _burst_lines(monkeypatch, loss=100, loss_burst=6) + check("download loss of 100% in runs of 6 says nothing about runs", + down == [], f"({down})") + up = _burst_lines(monkeypatch, asym=True, loss=0, loss_up=100, loss_burst=6) + check("and neither does an upload loss of 100%", up == [], f"({up})") + + +def test_an_upload_the_runs_cannot_carry_is_said_out_loud(monkeypatch): + """The upload walks its own chain from its own loss, so it clamps on its own. + + Until the external review (P3-4) only the download was asked, and 95% upload + loss in runs of 5 delivered 83% in silence. + """ + lines = _burst_lines(monkeypatch, asym=True, loss=5, loss_up=95, loss_burst=5) + check("the download says its gap, then the upload its clamp and its gap", + [key for key, _ in lines] == ["log.loss_burst_gap", + "log.loss_burst_clamped_up", + "log.loss_burst_gap_up"], f"({lines})") + said = dict(lines) + clamp = said.get("log.loss_burst_clamped_up", {}) + check("the clamp names what was asked and what the upload will really lose", + clamp.get("asked") == "95" and clamp.get("delivered") == "83.33", + f"({clamp})") + check("the upload's gap follows from what it really loses", + said.get("log.loss_burst_gap_up", {}).get("gap") == "6", f"({said})") + check("while the download keeps its own gap", + said.get("log.loss_burst_gap", {}).get("gap") == "100", f"({said})") + + +def test_upload_values_the_session_does_not_read_are_not_said(monkeypatch): + """With asymmetry off the upload fields are not read at all. + + A value left there from an earlier try is the ordinary path, not a corner + case, and a line about it would describe a link this session never produces. + The download, which then means both directions, is still said. + """ + lines = _burst_lines(monkeypatch, asym=False, loss=90, loss_up=95, loss_burst=5) + check("only the download lines, with the upload left over and switched off", + [key for key, _ in lines] == ["log.loss_burst_clamped", "log.loss_burst_gap"], + f"({lines})") + + +def test_the_summary_strip_names_the_runs_each_direction_really_gets(): + """The strip asks the same function, per direction, so it cannot claim runs + the engine is not producing - and does not hide the ones it is.""" + from beantester.summary import settings_summary + total = settings_summary(dict(loss=100, loss_burst=6), "en") + check("100% loss claims no runs", + "100% loss" in total and "in runs of" not in total, f"({total!r})") + down = settings_summary(dict(loss=5, loss_burst=20), "en") + check("a download loss in runs says so right after it", + "5% loss, in runs of 20 packets" in down, f"({down!r})") + up = settings_summary(dict(asym=True, loss_up=5, loss_burst=20, corrupt_up=1), "en") + check("an upload loss in runs says so too, in the same place", + "upload: 5% loss, in runs of 20 packets, 1% corruption" in up, f"({up!r})") + up_total = settings_summary(dict(asym=True, loss_up=100, loss_burst=6), "en") + check("100% upload loss claims no runs either", + "upload: 100% loss" in up_total and "in runs of" not in up_total, + f"({up_total!r})") + off = settings_summary(dict(asym=False, loss_up=5, loss_burst=20), "en") + check("with asymmetry off a leftover upload loss is not described", + "upload" not in off, f"({off!r})") diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 085f26c7..404ddf82 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -2386,6 +2386,67 @@ "new": " bad = True", "test": "test_the_run_counter_answers_did_this_fire_at_all", }, + { + # The shape the external review found (P1-1): a chain told to "stay bad" + # at 100% still leaves the bad state at r and lets that packet through. + "label": "burst loss: total loss walks the chain and lets packets through", + "file": "beantester/core.py", + "old": " # way. This branch is also what keeps the division below from raising.\n" + " return None", + "new": " # way. This branch is also what keeps the division below from raising.\n" + " return (1.0, r, 1.0)", + "test": "test_total_loss_loses_every_packet_whatever_the_run_length", + }, + { + # 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. + "label": "burst loss: the upload's clamp and gap go unsaid", + "file": "beantester/settings.py", + "old": " if g(\"asym\"):\n" + " _say_burst_loss_for(g(\"loss_up\"), g(\"loss_burst\"), _BURST_LINES_UP, log)", + "new": " pass", + "test": "test_an_upload_the_runs_cannot_carry_is_said_out_loud", + }, + { + # The other half of the same helper: with the switch off the upload values + # are not read, so a line about them describes a link nobody is producing. + "label": "burst loss: leftover upload values are said with asymmetry off", + "file": "beantester/settings.py", + "old": " if g(\"asym\"):\n" + " _say_burst_loss_for(g(\"loss_up\")", + "new": " if True:\n" + " _say_burst_loss_for(g(\"loss_up\")", + "test": "test_upload_values_the_session_does_not_read_are_not_said", + }, + { + # The upload half of the strip said its loss without its run length, so an + # upload losing in runs read exactly like one losing evenly (P3-4). + "label": "summary: the upload loss is described without its run length", + "file": "beantester/summary.py", + "old": " + _loss_parts(g, tr, num, \"loss_up\")", + "new": " + _plain_parts(g, tr, num, ((\"loss_up\", \"summary.loss\"),))", + "test": "test_the_summary_strip_names_the_runs_each_direction_really_gets", + }, + { + # A threshold instead of asking the function that DECIDES: the strip then + # claims runs the engine is not producing - at 100% loss, for one. + "label": "summary: the upload's run length is compared instead of asked", + "file": "beantester/summary.py", + "old": " if burst_loss_params(to_number(g(key)) / 100.0,\n" + " to_number(g(\"loss_burst\"))) is not None:", + "new": " if to_number(g(\"loss_burst\")) > 1.0:", + "test": "test_the_summary_strip_names_the_runs_each_direction_really_gets", + }, + { + # The same shortcut in the download half, which asks inline (it says there + # why): at 100% loss the strip would promise runs the engine never makes. + "label": "summary: the download's run length is compared instead of asked", + "file": "beantester/summary.py", + "old": " if burst_loss_params(to_number(g(\"loss\")) / 100.0,\n" + " to_number(g(\"loss_burst\"))) is not None:", + "new": " if to_number(g(\"loss_burst\")) > 1.0:", + "test": "test_the_summary_strip_names_the_runs_each_direction_really_gets", + }, { # The plainest way to break convention 36, and the one a session in a # hurry would reach for: an update check, a crash reporter, a "quick