From 570fd9728694fe627d718406e8b7a32c2bde1ad9 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:05:31 +0200 Subject: [PATCH 1/6] feat(core): resolve impairment values per direction `decide()` reads the seven values from step 8 down (loss, corruption, duplication, latency, jitter, spike probability and spike length) from one value set indexed by `is_outbound`, the way `_bucket` and `_loss_bad` are already indexed. Nothing above the core uses it yet: with the switch off both directions get the same object, so the packet path cannot read whether asymmetry is on and there is no state there to get wrong. The shape was measured rather than picked. Against bench_decide.py on this tree (4750 ns/packet, impairing mix, spread 6.6%) one index plus seven attribute reads costs ~39 ns/packet, while indexing seven separate per-direction tuples costs ~172 ns. Both sit under the spread the rig can resolve. `decide()` gains no branch, which matters because it sits exactly on the mccabe ceiling of 27 pinned in pyproject.toml. The burst-loss chain moves with the values, and so does its reset. `p` is derived from the loss, so two directions losing different amounts need two chains. While they shared one pair of probabilities, re-deriving it reset both runs, so raising the upload loss ended the run the download direction was in the middle of: a direction nobody touched, losing its run because the other one moved. The run length itself stays symmetric on purpose. Both mutations were run, not assumed. Restoring the shared reset fails the new guard on "the download run survives a change to the upload", and deriving both chains from the download loss fails it on "with the probability its own new loss implies". Co-Authored-By: Claude Opus 5 --- beantester/core.py | 203 +++++++++++++++++++++++++++++++-------- beantester/engine.py | 3 + tests/test_burst_loss.py | 63 ++++++++++-- tests/test_core.py | 70 ++++++++++++++ 4 files changed, 293 insertions(+), 46 deletions(-) diff --git a/beantester/core.py b/beantester/core.py index 2551796..d0846af 100644 --- a/beantester/core.py +++ b/beantester/core.py @@ -309,6 +309,42 @@ def compile_endpoint(ip, port): KIND_INT, "fields.port", bounds=PORT_BOUNDS)) +class _Impairments: + """Every value ONE direction applies, resolved once per apply. + + ``decide()`` picks the set for the packet's direction with a single index and + then reads plain attributes, which is why asymmetry costs no BRANCH: the + function sits exactly on the complexity ceiling pinned in ``pyproject.toml`` + (27, measured), so a per-direction ``if`` in the packet path would have to be + paid for by taking something else out. + + MEASURED 2026-09-05, against ``internal_tools/bench_decide.py`` on the same + tree (4750 ns/packet for the impairing mix, spread 6.6%): one index plus seven + attribute reads costs ~39 ns/packet, while indexing seven separate + per-direction tuples costs ~172 ns - a fifth of the price for the same answer, + and both are under the spread the rig itself can resolve. The canary drifted + on that run, so read those two against each other and not as absolutes. + + The burst-loss chain rides along because its transition probabilities are + DERIVED from the loss (``p = loss * r / (1 - loss)``), so two directions + losing different amounts need two different chains - see ``_recompute``. + """ + __slots__ = ("loss", "corrupt", "dup", "latency_s", "jitter_s", + "spike_prob", "spike_s", "burst_p", "burst_r") + + def __init__(self, loss, corrupt, dup, latency_s, jitter_s, + spike_prob, spike_s, burst_p, burst_r): + self.loss = loss + self.corrupt = corrupt + self.dup = dup + self.latency_s = latency_s + self.jitter_s = jitter_s + self.spike_prob = spike_prob + self.spike_s = spike_s + self.burst_p = burst_p + self.burst_r = burst_r + + class BeanCore: """Decide what to do with a single packet. No network dependency.""" @@ -335,8 +371,6 @@ def __init__(self): # transition probabilities are derived once per apply (_recompute_burst) # and there is one chain PER DIRECTION - see _loses for why. self.loss_burst = 0.0 - self._burst_p = None - self._burst_r = 0.0 self._loss_bad = {True: False, False: False} # Runs STARTED, both directions together. The engine merges it into the # statistics snapshot, because "did the model fire at all" is not @@ -348,6 +382,23 @@ def __init__(self): self.dup = 0.0 self.latency_s = 0.0 self.jitter_s = 0.0 + # Asymmetry: the seven values above (plus the two spike values below) + # describe the DOWNLOAD direction once ``asymmetric`` is on, and these + # describe the UPLOAD direction. Off - the default, and what every file + # written before this existed decodes to - they are unread and one set of + # values applies both ways, which is what this tool did before. + # + # A switch rather than "empty means the same as download": an empty + # numeric field means ZERO everywhere else in this program, and in a + # repro command the inheritance would not be visible at all. The GUI + # copies the download values across when the switch goes on, so the + # second column is never blank and never has to be explained. + self.asymmetric = False + self.loss_up = 0.0 + self.corrupt_up = 0.0 + self.dup_up = 0.0 + self.latency_up_s = 0.0 + self.jitter_up_s = 0.0 self.rate_down = 0 # B/s (inbound), 0 = unlimited self.rate_up = 0 # B/s (outbound) self._bucket = {True: 0.0, False: 0.0} @@ -413,6 +464,8 @@ def __init__(self): self.max_size = 0 self.spike_prob = 0.0 # chance of a latency spike self.spike_s = 0.0 # extra delay during a spike + self.spike_prob_up = 0.0 # ...and the same pair for the upload + self.spike_up_s = 0.0 # direction, read only when asymmetric # NAT mapping expiry self.nat_timeout_s = 0.0 # >0 => after this many idle s the mapping disappears # RST injection (connection reset) @@ -428,6 +481,10 @@ def __init__(self): self._flow_last = _FlowTable() # flowkey -> last activity self._reset_until = _FlowTable() # flowkey -> RST cooldown deadline self._prune_next = 0.0 # earliest time the next rotation may run + # Derived, never set from outside: one resolved value set per direction, + # indexed by ``is_outbound`` exactly like ``_bucket`` and ``_loss_bad``. + self._dir = (None, None) + self._recompute() # -- setters ----------------------------------------------------------- # @staticmethod @@ -485,9 +542,35 @@ def set_params(self, loss_pct, corrupt_pct, dup_pct, self.jitter_s = max(0.0, jitter_ms) / 1000.0 self.rate_down = self._rate_bps(down_kbps) self.rate_up = self._rate_bps(up_kbps) - # The burst chain is derived from the loss AND from the run length, - # so it has to be re-derived here too - see _recompute_burst. - self._recompute_burst() + # Every per-direction value is derived from the fields above (and, + # for the burst chain, from the run length too), so it has to be + # re-derived here - see _recompute. + self._recompute() + + def set_asymmetry(self, enabled, loss_pct, corrupt_pct, dup_pct, + latency_ms, jitter_ms, spike_prob_pct, spike_ms): + """The values the UPLOAD direction applies, and whether they are used. + + Separate from ``set_params`` for the reason ``set_loss_burst`` gives + below - that signature is called positionally by 60-odd tests and rigs - + and separate from ``set_spike`` because these eight arrive from one + switch in the form and have to land inside ONE lock hold, or a packet + could be judged with the new upload loss and the old upload latency. + + ``enabled`` is stored rather than inferred from the values: "upload loses + nothing" is a legitimate asymmetric link, and inferring the switch from a + row of zeros would make that link impossible to ask for. + """ + with self._lock: + self.asymmetric = bool(enabled) + self.loss_up = clamp01(loss_pct / 100.0) + self.corrupt_up = clamp01(corrupt_pct / 100.0) + self.dup_up = clamp01(dup_pct / 100.0) + self.latency_up_s = max(0.0, latency_ms) / 1000.0 + self.jitter_up_s = max(0.0, jitter_ms) / 1000.0 + self.spike_prob_up = clamp01(spike_prob_pct / 100.0) + self.spike_up_s = max(0.0, spike_ms) / 1000.0 + self._recompute() def set_loss_burst(self, mean_packets): """Average length, in packets, of a run of lost packets. 0 = independent. @@ -499,32 +582,65 @@ def set_loss_burst(self, mean_packets): """ with self._lock: self.loss_burst = max(0.0, float(mean_packets or 0.0)) - self._recompute_burst() - - def _recompute_burst(self): - """Re-derive the chain from the two fields that feed it. - - Called from BOTH setters that can change either half, and that is the - point. ``p`` depends on the loss as much as on the run length, so a - single owner would leave the ORDER of two setter calls deciding whether - the answer is right - and ``set_params`` is called on its own by tests, - by rigs and by anything that only means to change the loss. The symptom - would have been quiet: the delivered loss drifting away from the field - that asked for it, with nothing going red. - - Only a REAL change restarts the chain. A scenario stepping the speed - limit calls every setter on every step change (``scenario_runner``), and - restarting here unconditionally would cut every run in flight - at 50 - packets a second a run of 20 lasts 400 ms, so most of them. + self._recompute() + + def _recompute(self): + """Re-derive both directions' value sets from the fields that feed them. + + Called from EVERY setter that can change any half, and that is the point. + ``p`` depends on the loss as much as on the run length, so a single owner + would leave the ORDER of two setter calls deciding whether the answer is + right - and ``set_params`` is called on its own by tests, by rigs and by + anything that only means to change the loss. The symptom would have been + quiet: the delivered loss drifting away from the field that asked for it, + with nothing going red. + + With the asymmetry switch off both directions get the SAME object, so + "asymmetry is off" is not a state the packet path can read - there is + nothing there to get wrong, and one apply allocates one value set instead + of two. + + Only a REAL change restarts a chain, and only the direction that changed. + A scenario stepping the speed limit calls every setter on every step + change (``scenario_runner``), and restarting unconditionally would cut + every run in flight - at 50 packets a second a run of 20 lasts 400 ms, so + most of them. 🔴 Per DIRECTION for the same reason, which is what an + asymmetric link makes reachable: while the chains shared one pair of + probabilities, raising the UPLOAD loss re-derived the pair and cut the + run the DOWNLOAD direction was in the middle of - a direction nobody + touched, losing its run because the other one moved. + """ + down = self._resolve(self.loss, self.corrupt, self.dup, self.latency_s, + self.jitter_s, self.spike_prob, self.spike_s) + if self.asymmetric: + up = self._resolve(self.loss_up, self.corrupt_up, self.dup_up, + self.latency_up_s, self.jitter_up_s, + self.spike_prob_up, self.spike_up_s) + else: + up = down + for outbound, fresh in ((False, down), (True, up)): + old = self._dir[outbound] + if old is None or (fresh.burst_p, fresh.burst_r) != (old.burst_p, old.burst_r): + self._loss_bad[outbound] = False + self._dir = (down, up) + + def _resolve(self, loss, corrupt, dup, latency_s, jitter_s, spike_prob, spike_s): + """One direction's fields -> the value set ``decide()`` reads. + + The run length is deliberately NOT per direction: a tester answers "how + much loss" per direction and "how long a run" once, and two run lengths + would be a fourth number to reconcile for a distinction no link makes + obvious. Two directions losing different AMOUNTS already get different + transition probabilities out of the same run length, which is the part + that would otherwise be wrong. """ - params = burst_loss_params(self.loss, self.loss_burst) + params = burst_loss_params(loss, self.loss_burst) p = None if params is None else params[0] r = 0.0 if params is None else params[1] - if (p, r) != (self._burst_p, self._burst_r): - self._loss_bad[True] = self._loss_bad[False] = False - self._burst_p, self._burst_r = p, r + return _Impairments(loss, corrupt, dup, latency_s, jitter_s, + spike_prob, spike_s, p, r) - def _loses(self, rng, is_outbound): + def _loses(self, rng, is_outbound, imp): """Does this packet fall to the configured loss? Step 8's whole question. Independent by default - the draw this tool has always made, reached @@ -547,13 +663,13 @@ def _loses(self, rng, is_outbound): with a process target the run length is counted in the target's packets, which is the number the tester meant. """ - if self._burst_p is None: - return rng.random() < self.loss + if imp.burst_p is None: + return rng.random() < imp.loss bad = self._loss_bad[is_outbound] if bad: - if rng.random() < self._burst_r: + if rng.random() < imp.burst_r: bad = False - elif rng.random() < self._burst_p: + elif rng.random() < imp.burst_p: bad = True self.loss_bursts += 1 self._loss_bad[is_outbound] = bad @@ -743,6 +859,10 @@ def set_spike(self, prob_pct, spike_ms): with self._lock: self.spike_prob = clamp01(prob_pct / 100.0) self.spike_s = max(0.0, spike_ms) / 1000.0 + # Both values ride in the per-direction set, so this setter re-derives + # it like every other one. Missing this line would leave the packet + # path on the spike from the PREVIOUS apply. + self._recompute() def set_nat(self, timeout_s): with self._lock: @@ -1067,20 +1187,25 @@ def decide(self, size, is_outbound, local_port, now, rng, # branch here: this function sits ON the complexity ceiling pinned in # pyproject.toml, where the rule is to move code out instead of # raising the number. Measured after the change: still 27. - if self.loss > 0 and self._loses(rng, is_outbound): + # The values from here down are read PER DIRECTION: one index, then + # plain attributes. Resolved in _recompute, so nothing below has to + # ask whether asymmetry is on - see _Impairments for the measurement + # that chose this shape over indexing each field separately. + imp = self._dir[is_outbound] + if imp.loss > 0 and self._loses(rng, is_outbound, imp): return Decision(True, False, []) # 9) corruption - corrupt = self.corrupt > 0 and rng.random() < self.corrupt + corrupt = imp.corrupt > 0 and rng.random() < imp.corrupt # 10) latency + jitter + latency spike - delay = self.latency_s - if self.jitter_s > 0: - delay += rng.uniform(-self.jitter_s, self.jitter_s) + delay = imp.latency_s + if imp.jitter_s > 0: + delay += rng.uniform(-imp.jitter_s, imp.jitter_s) if delay < 0: delay = 0.0 - if self.spike_prob > 0 and rng.random() < self.spike_prob: - delay += self.spike_s + if imp.spike_prob > 0 and rng.random() < imp.spike_prob: + delay += imp.spike_s release = now + delay # 11) throughput limit (time-variable, per-direction token bucket with @@ -1117,7 +1242,7 @@ def decide(self, size, is_outbound, local_port, now, rng, releases = [release] # 12) duplication - if self.dup > 0 and rng.random() < self.dup: + if imp.dup > 0 and rng.random() < imp.dup: dup_release = release + rng.uniform(0.0, 0.02) # a duplicate is a second copy on the wire: charge the bucket for it, # or the shaped link quietly carries (1 + dup%) of its limit. If the diff --git a/beantester/engine.py b/beantester/engine.py index a0443bf..11e6633 100644 --- a/beantester/engine.py +++ b/beantester/engine.py @@ -316,6 +316,9 @@ def set_buffer(self, *a): def set_loss_burst(self, *a): self.core.set_loss_burst(*a) + def set_asymmetry(self, *a): + self.core.set_asymmetry(*a) + def set_target(self, active, ports=None): """Point the engine at a set of local ports (or a live port container). diff --git a/tests/test_burst_loss.py b/tests/test_burst_loss.py index 75bcbeb..7dea9d5 100644 --- a/tests/test_burst_loss.py +++ b/tests/test_burst_loss.py @@ -40,6 +40,18 @@ def _core(loss=0.0, burst=0.0): return core +def _chain(core, outbound=True): + """The transition probabilities one direction is walking. + + They moved into the per-direction value set when asymmetry arrived: two + directions losing different amounts need two chains, because ``p`` is derived + from the loss. With the switch off both directions share one set, so either + index answers - and a test that says which one it means still reads right + when they differ. + """ + return core._dir[outbound] + + def _drops(core, packets=PACKETS, seed=7, alternate=False): """Run packets through decide(); return (dropped, {direction: [run lengths]}). @@ -249,14 +261,14 @@ def test_changing_only_the_loss_re_derives_the_chain(): chain would still be a perfectly valid chain for the OLD number. """ core = _core(loss=5, burst=20) - before = core._burst_p + before = _chain(core).burst_p core.set_params(20, 0, 0, 0, 0, 0, 0) - check("a new loss gives a new transition probability", core._burst_p != before, - f"(p stayed {before})") + check("a new loss gives a new transition probability", + _chain(core).burst_p != before, f"(p stayed {before})") expected, _r, _a = burst_loss_params(0.2, 20.0) check("and it is the one the new loss implies", - abs(core._burst_p - expected) < 1e-12, - f"(p={core._burst_p}, expected {expected})") + abs(_chain(core).burst_p - expected) < 1e-12, + f"(p={_chain(core).burst_p}, expected {expected})") def test_re_applying_the_same_settings_does_not_cut_a_run_in_flight(): @@ -356,12 +368,49 @@ def test_the_run_counter_reaches_the_statistics_snapshot(): def test_turning_bursts_off_returns_to_the_independent_draw(): core = _core(loss=10, burst=30) - check("armed", core._burst_p is not None, "") + check("armed", _chain(core).burst_p is not None, "") core.set_loss_burst(0) - check("disarmed", core._burst_p is None, f"(p={core._burst_p})") + check("disarmed", _chain(core).burst_p is None, f"(p={_chain(core).burst_p})") dropped, runs = _drops(core, packets=50000) check("and the losses stop clustering", _mean(runs[True]) < 1.2, f"(mean run {_mean(runs[True]):.2f})") check("while still losing about the configured share", abs(100.0 * dropped / 50000 - 10.0) < 1.0, f"(delivered {100.0 * dropped / 50000:.2f}%)") + + +def test_raising_the_upload_loss_does_not_cut_a_download_run_in_flight(): + """One chain per direction means one PAIR OF PROBABILITIES per direction. + + While both directions shared a single pair, re-deriving it reset both runs - + so changing a value for a direction nobody was asking about ended the run the + other direction was in the middle of. Unreachable until the two directions + could hold different losses, and silent when it happened: the run simply came + out shorter than the tester asked for, in a direction they had not touched. + """ + core = BeanCore() + core.set_params(50, 0, 0, 0, 0, 0, 0) + core.set_loss_burst(50) + core.set_asymmetry(True, 50, 0, 0, 0, 0, 0, 0) # same loss both ways, for now + core.reset_buckets(0.0) + + rng = random.Random(3) + for i in range(200): # get the DOWNLOAD into a run + core.decide(1200, False, 5000, i * 0.001, rng, remote_ip="1.2.3.4", + remote_port=443, is_tcp=True) + if core._loss_bad[False]: + break + check("the download chain reached a bad run to test with", + core._loss_bad[False], "") + + core.set_asymmetry(True, 90, 0, 0, 0, 0, 0, 0) # change the UPLOAD only + check("the download run survives a change to the upload", + core._loss_bad[False], "(the run was cut)") + check("...and the upload chain did restart", + not core._loss_bad[True], "") + check("...with the probability its own new loss implies", + abs(_chain(core, True).burst_p - burst_loss_params(0.9, 50.0)[0]) < 1e-12, + f"(p={_chain(core, True).burst_p})") + 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})") diff --git a/tests/test_core.py b/tests/test_core.py index fb2eb1d..56b04bb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1339,3 +1339,73 @@ def test_the_size_ceiling_holds_inside_a_single_second(): peak = max(peak, len(table)) check("the table never passes its ceiling, whatever the clock does", peak <= 1000, f"(peak={peak})") + + +# --------------------------------------------------------------------------- # +# Asymmetry: separate values for download and upload +# --------------------------------------------------------------------------- # +def _delay(core, outbound, rng=None): + """The delay one direction adds, with no jitter and no spike in the way.""" + rng = rng or random.Random(1) + return core.decide(100, outbound, None, 5.0, rng).releases[0] - 5.0 + + +def test_with_the_switch_off_both_directions_get_the_same_values(): + """Values for the upload direction are STORED but unread until the switch. + + This is what every profile, preset and repro command written before + asymmetry existed decodes to, so it is the behaviour that must not move. + """ + core = BeanCore() + core.set_params(0, 0, 0, 100, 0, 0, 0) + core.set_asymmetry(False, 0, 0, 0, 20, 0, 0, 0) # upload values, unused + check("download unchanged", abs(_delay(core, False) - 0.1) < 1e-9, + f"({_delay(core, False)})") + check("upload gets the same value, not the stored one", + abs(_delay(core, True) - 0.1) < 1e-9, f"({_delay(core, True)})") + + +def test_the_switch_makes_the_two_directions_differ(): + core = BeanCore() + core.set_params(0, 0, 0, 200, 0, 0, 0) + core.set_asymmetry(True, 0, 0, 0, 30, 0, 0, 0) + check("download keeps the base value", abs(_delay(core, False) - 0.2) < 1e-9, + f"({_delay(core, False)})") + check("upload uses its own", abs(_delay(core, True) - 0.03) < 1e-9, + f"({_delay(core, True)})") + + +def test_an_upload_that_impairs_nothing_is_a_legal_link(): + """"Fast up, slow down" is the shape of most consumer links, so a row of + zeros for the upload must be reachable - which is why the switch is stored + rather than inferred from the values.""" + core = BeanCore() + core.set_params(100, 0, 0, 500, 0, 0, 0) # 100% loss downward + core.set_asymmetry(True, 0, 0, 0, 0, 0, 0, 0) + rng = random.Random(5) + down = [core.decide(100, False, None, 0.0, rng).drop for _ in range(200)] + up = [core.decide(100, True, None, 0.0, rng).drop for _ in range(200)] + check("every download packet is dropped", all(down), f"({sum(down)}/200)") + check("and no upload packet is", not any(up), f"({sum(up)}/200)") + check("while the upload adds no delay", abs(_delay(core, True)) < 1e-9, + f"({_delay(core, True)})") + + +def test_every_impairment_can_differ_by_direction(): + """The accounting test: each value the switch covers must actually be read + per direction. A field wired into the registry but not into the value set + would impair BOTH directions with the download number, and the only symptom + would be a number quietly ignored.""" + core = BeanCore() + core.set_params(10, 20, 30, 100, 40, 0, 0) + core.set_spike(50, 60) + core.set_asymmetry(True, 1, 2, 3, 4, 5, 6, 7) + down, up = core._dir[False], core._dir[True] + for name, expected_down, expected_up in ( + ("loss", 0.10, 0.01), ("corrupt", 0.20, 0.02), ("dup", 0.30, 0.03), + ("latency_s", 0.100, 0.004), ("jitter_s", 0.040, 0.005), + ("spike_prob", 0.50, 0.06), ("spike_s", 0.060, 0.007)): + check(f"{name} is per direction", + abs(getattr(down, name) - expected_down) < 1e-9 + and abs(getattr(up, name) - expected_up) < 1e-9, + f"(down={getattr(down, name)}, up={getattr(up, name)})") From 3ea6ae100823337ba85be576b50543abe613d549 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:23:06 +0200 Subject: [PATCH 2/6] feat(settings): asymmetry switch and seven upload values One switch (`asym`) and seven upload values reach the field registry, the settings model, the parser, the reproduction command and the session summary. They live in a card of their own rather than beside each field they mirror: the latency and impairment cards pair their fields on purpose, "Loss" next to the run length that shapes it, and interleaving an upload value into those rows would break every one of those pairs. The upload fields declare both `impairs` and the new `Field.live_when`, and each half alone is a real defect. Without `impairs`, `--asym --loss-up 50` cuts half of everything this machine sends, with no target and no deadline, and starts in silence, because the blast-radius warning looks at the download loss and that is zero. Without `live_when`, a value left behind after the switch goes back off warns about a run that touches nothing, which is not a corner case but the ordinary path of trying the feature and changing your mind. Both mutations were run against the new guards and both fail them. The flag names cost 18 working abbreviations (`--lat`, `--jit`, `--j`, `--cor`, `--spike-p` and longer forms), measured before the names were chosen, the same cost `--loss-burst` was allowed to charge for `--los`. Every full flag survives, so no stored reproduction command and no documented example moves. `--up-latency` would have cost one prefix and was refused: every other modifier in this parser reads noun-then-modifier, and `--up-` would also read as belonging to `--up`, the upload speed limit. Two ratchets took the routine door rather than moving. `settings_to_cli` crossed into the complexity crowd band, so its seven identical boolean branches became a table. The upload half of the summary went into its own function instead of into `settings_summary`, which sits one step below the ceiling. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++++++++ README.md | 4 ++ beantester/cli.py | 20 +++++++++ beantester/fields.py | 80 +++++++++++++++++++++++++++++++++++- beantester/gui/form.py | 35 +++++++++++----- beantester/repro.py | 59 ++++++++++++++++---------- beantester/settings.py | 27 +++++++++++- beantester/summary.py | 32 +++++++++++++++ lang/en.json | 20 +++++++++ lang/pl.json | 20 +++++++++ lang/zh.json | 20 +++++++++ tests/test_cli_runtime.py | 72 ++++++++++++++++++++++++++++++++ tests/test_field_registry.py | 9 +++- tests/test_passthrough.py | 5 +++ 14 files changed, 386 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 496b0a4..fc9796b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Added +- **Different values for uploads and downloads.** A new "Asymmetry" card with a tick box: + leave it off and one set of numbers applies both ways, exactly as before. Tick it and the + fields higher up the page describe downloads only, while seven new fields describe uploads - + latency, jitter, spike chance and size, loss, corruption and duplication. The new fields start + as copies of what you already typed, so switching it on changes nothing until you edit them. + Real home and mobile lines are not the same in both directions, and an app that browses fine + can still struggle to send: a video call, a file upload, a game reporting your moves. On the + command line: `--asym` plus `--loss-up`, `--corrupt-up`, `--dup-up`, `--latency-up`, + `--jitter-up`, `--spike-prob-up` and `--spike-ms-up`. Profiles remember all of it, and the + session description says what the upload half is doing. + +### Changed + +- **Some command-line shortcuts stopped working, and the full flags did not.** Adding the + upload flags means `--latency` is no longer the only option starting with "latency", so + short forms like `--lat`, `--jit`, `--j`, `--cor` and `--spike-p` are now ambiguous and are + refused. Every full flag still works, so saved reproduction commands and every example in + this documentation are unaffected - only hand-typed abbreviations need writing out in full. + - **A blocked connection can be refused instead of ignored.** A new "Refuse blocked connections" checkbox in the Block card, and `--block-reject`. Without it a blocked connection gets no answer and the program you are testing waits until it gives up on its diff --git a/README.md b/README.md index ebdebd8..8f920f5 100644 --- a/README.md +++ b/README.md @@ -760,6 +760,10 @@ BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson | `--rst-prob` `--rst-cooldown` | % / s | percentage of connections torn with RST and how long the tear-down is held | | `--flap-period` `--flap-down` | s / % | cyclic link outage: how often and for what fraction of the period | | `--rate-schedule` | - | changing throughput: `"time:download:upload,..."` in KB/s, looped | +| `--asym` | - | give uploads their own values. Without it one set of numbers applies both ways, which is the default | +| `--loss-up` `--corrupt-up` `--dup-up` | % | the same three impairments, for packets this machine SENDS. Used only with `--asym` | +| `--latency-up` `--jitter-up` | ms | delay and its variation, for packets this machine SENDS. Used only with `--asym` | +| `--spike-prob-up` `--spike-ms-up` | % / ms | the occasional longer delay, for packets this machine SENDS. Used only with `--asym` | | `--ipv4-only` `--ipv6-only` | - | impair one address family only. The other keeps flowing untouched - this aims the tool, it does not block a protocol. Works with `--dst-ip` empty too, which means all addresses. Both flags at once exclude each other, nothing is impaired, and the log says so | | `--lan-mode` | - | LAN mode: cut off the internet (public addresses), keep the local network | | `--internet-only` | - | the mirror: cut off the local network (10.x, 172.16-31.x, 192.168.x, link-local, CGNAT), keep the internet. Loopback keeps working. Careful: DNS asked of your router is local traffic, so the internet can stop working with it | diff --git a/beantester/cli.py b/beantester/cli.py index feb25b3..dbf80d9 100644 --- a/beantester/cli.py +++ b/beantester/cli.py @@ -202,6 +202,26 @@ def build_arg_parser(): "bounds the queueing delay a rate-limited link builds up " "before it drops (bufferbloat)") _add_scope_arguments(p) + # Asymmetry. The flags read `-up`, which is how every other modifier in + # this parser reads (--loss-burst, --spike-prob, --rst-cooldown, --flap-down). + # 🔴 The abbreviation cost was MEASURED before the names were chosen, exactly + # as it was for --loss-burst: `allow_abbrev` is on (ADR 2026-08-02), so a + # second option starting with `latency` makes `--lat` ambiguous. 18 prefixes + # that work today stop working (--lat, --jit, --j, --cor, --spike-p and the + # longer forms of each). Every FULL flag survives, because an exact match + # beats a prefix one, so no repro command and no documented example moves. + # Guarded by test_cli_runtime.py::test_the_flags_that_gained_an_up_neighbour_still_work. + p.add_argument("--asym", action="store_true", + help="apply the --*-up values to the upload direction. Without " + "it the link is symmetric and those values are unused") + p.add_argument("--loss-up", type=float, help="packet loss, upload [%%]") + p.add_argument("--corrupt-up", type=float, help="corruption, upload [%%]") + p.add_argument("--dup-up", type=float, help="duplication, upload [%%]") + p.add_argument("--latency-up", type=float, help="latency, upload [ms]") + p.add_argument("--jitter-up", type=float, help="jitter, upload [ms]") + p.add_argument("--spike-prob-up", type=float, + help="latency spike probability, upload [%%]") + p.add_argument("--spike-ms-up", type=float, help="latency spike size, upload [ms]") p.add_argument("--syn-drop", type=float, help="dropped TCP SYN rate [%%]") p.add_argument("--max-size", type=int, help="MTU black hole: drop packets > N B") p.add_argument("--spike-prob", type=float, help="latency spike probability [%%]") diff --git a/beantester/fields.py b/beantester/fields.py index 73b8f32..cc453e7 100644 --- a/beantester/fields.py +++ b/beantester/fields.py @@ -89,6 +89,15 @@ class Field(NamedTuple): # tests/test_passthrough.py::test_a_parameter_at_its_maximum_still_damages_nothing, # which used to be two field names typed out by hand in that file. parameter_of: str = "" + # Key of a SWITCH that must be on for this field to reach the packet path at + # all. Different from ``overridden_by``, which names a field that makes this + # one inert when it IS set - this names one that makes it inert when it is + # NOT. It exists because the blast-radius question cannot be answered + # statically for such a field: an upload loss of 50% damages every packet + # going out, or nothing whatsoever, and which of the two is decided by + # another field's value. See ``settings.armed_global_impairments``, which + # already carries one case the registry cannot state on its own. + live_when: str = "" FIELD_DEFS = ( @@ -207,6 +216,55 @@ class Field(NamedTuple): bounds=PCT, width=6, tip="tips.dup", in_profile=True, cli="dup", impairs=IMPAIRS_ALL), + # -- asymmetry (separate values for the upload direction) -------------- # + # A SWITCH rather than "leave it empty to mean the same as download". An + # empty numeric field means zero everywhere else in this program, and in a + # repro command the inheritance would not be visible at all - so the reading + # a newcomer would give an empty box is the one that would be wrong. With the + # switch off nothing here is read and the form is the form it always was; + # turning it on copies the download values across, so the second set is never + # blank and never has to be explained. + # + # It arms nothing on its own (every value below defaults to 0, and this + # switch only decides WHICH set of values applies), so it declares no + # ``impairs`` - see tests/test_passthrough.py. + Field("asym", BOOL, "fields.asym", "asymmetry", + tip="tips.asym", span=True, cli="asym", in_profile=True, + help_title="dialogs.asym_help_title", help_body="dialogs.asym_help"), + # 🔴 ``impairs=IMPAIRS_ALL`` **and** ``live_when="asym"`` on all seven. + # Both halves are load-bearing and each alone is wrong: + # * without ``impairs``, ``--asym --loss-up 50`` cuts half the traffic on + # this machine and raises NO blast-radius warning, because the download + # loss it would look at is zero. That is the rule this project puts in + # red: never damage traffic globally without saying so. + # * without ``live_when``, a value left behind after the switch goes back + # off warns about a run that touches nothing - and that is not a corner, + # it is what toggling the switch off normally leaves behind. + # ``settings.armed_global_impairments`` reads both. + Field("latency_up", NUMBER, "fields.latency_up", "asymmetry", unit="ms", + bounds=MS, tip="tips.latency_up", in_profile=True, preset_key="lat_up", + cli="latency-up", impairs=IMPAIRS_ALL, live_when="asym"), + Field("jitter_up", NUMBER, "fields.jitter_up", "asymmetry", unit="ms", + bounds=MS, tip="tips.jitter_up", in_profile=True, preset_key="jit_up", + cli="jitter-up", impairs=IMPAIRS_ALL, live_when="asym"), + Field("spike_prob_up", NUMBER, "fields.spike_prob_up", "asymmetry", unit="%", + bounds=PCT, width=6, tip="tips.spike_up", in_profile=True, + cli="spike-prob-up", impairs=IMPAIRS_ALL, live_when="asym"), + # Parameter of the spike ABOVE it, exactly as spike_ms is of spike_prob: it + # sits behind that gate in decide() step 10 and arms nothing by itself. + Field("spike_ms_up", NUMBER, "fields.spike_ms_up", "asymmetry", unit="ms", + bounds=MS, width=8, tip="tips.spike_up", in_profile=True, + cli="spike-ms-up", parameter_of="spike_prob_up", live_when="asym"), + Field("loss_up", NUMBER, "fields.loss_up", "asymmetry", unit="%", + bounds=PCT, width=6, tip="tips.loss_up", in_profile=True, + cli="loss-up", impairs=IMPAIRS_ALL, live_when="asym"), + Field("corrupt_up", NUMBER, "fields.corruption_up", "asymmetry", unit="%", + bounds=PCT, width=6, tip="tips.corrupt_up", in_profile=True, + cli="corrupt-up", impairs=IMPAIRS_ALL, live_when="asym"), + Field("dup_up", NUMBER, "fields.duplication_up", "asymmetry", unit="%", + bounds=PCT, width=6, tip="tips.dup_up", in_profile=True, + cli="dup-up", impairs=IMPAIRS_ALL, live_when="asym"), + # -- flapping ---------------------------------------------------------- # # in_profile: the outage is PERIODIC and phase-locked to the session start # (core.decide step 5), so a profile carrying these two reproduces a link @@ -388,6 +446,15 @@ class Section(NamedTuple): # "how much" and "in what shape" read as one setting or as neither. Section("impairments", "frames.impairments", ("loss", "loss_burst", "corrupt", "dup"), columns=2), + # Its own card rather than a second value beside each field it mirrors. The + # two sections above pair their fields on purpose - "Loss" sits next to the + # run length that shapes it, latency next to its jitter - and interleaving an + # upload value into those rows would break every one of those pairs to gain + # an adjacency that only matters while the switch is on. One card, one + # switch, and the seven values it governs read as the one thing they are. + Section("asymmetry", "frames.asymmetry", + ("asym", "latency_up", "jitter_up", "spike_prob_up", "spike_ms_up", + "loss_up", "corrupt_up", "dup_up"), columns=2), Section("flapping", "frames.flapping", ("flap_period", "flap_down"), columns=2), # columns=2 for the family pair only: both expression fields above carry # span=True and keep a row each regardless, so this changes nothing they do. @@ -445,7 +512,18 @@ def expression_fields(): # reads them, and tests/test_passthrough.py derives its pass-through invariant from # them, so a new impairment is declared once instead of remembered twice. IMPAIRING_KEYS = tuple(f.key for f in FIELD_DEFS if f.impairs) -GLOBALLY_IMPAIRING_KEYS = tuple(f.key for f in FIELD_DEFS if f.impairs == IMPAIRS_ALL) +# Damages every captured packet WHENEVER IT IS SET - nothing else has to be +# switched on first, so the registry can answer the blast-radius question from +# the value alone. +GLOBALLY_IMPAIRING_KEYS = tuple(f.key for f in FIELD_DEFS + if f.impairs == IMPAIRS_ALL and not f.live_when) +# ...and the ones where it cannot: the same damage, but only while the switch +# named by ``live_when`` is on. Kept out of the tuple above so that a value left +# behind by a switch that is now off is not reported as armed - and folded back +# in by ``settings.armed_global_impairments``, which is the only place that has +# the other field's value to look at. +CONDITIONAL_IMPAIRING_KEYS = tuple(f.key for f in FIELD_DEFS + if f.impairs == IMPAIRS_ALL and f.live_when) # Damages only what it NAMES - which stops being a limit the moment the expression # names everything. `settings.armed_global_impairments` promotes one of these to a # global impairment when its expression covers the whole space; see it for why. diff --git a/beantester/gui/form.py b/beantester/gui/form.py index efbe1ab..08ee6d5 100644 --- a/beantester/gui/form.py +++ b/beantester/gui/form.py @@ -237,6 +237,13 @@ def _place_one(self, row, field, sec): widget.pack(side="left", anchor="w", padx=(0, _gap_after(field))) add_tooltip(widget, field.tip) self.entries[field.key] = widget + # A checkbox can declare a help sheet too, and the switch that turns + # a whole card on is the field most likely to need one: a tooltip has + # to fit on a hover, and "the values higher up the page now describe + # downloads only" is a paragraph, not a phrase. Until asymmetry + # arrived every field with a sheet happened to take an entry box, so + # the branch below was the only one that drew the button. + self._add_help_button(widget.master, field) return if field.kind == F.CHOICE: @@ -284,16 +291,8 @@ def _place_one(self, row, field, sec): help_btn.pack(side="left", padx=(scaled(8), 0)) add_tooltip(help_btn, "tips.match_syntax") self.helps[field.key] = help_btn - elif field.help_body: - # Same "?" affordance for any registry field that declares its own - # help sheet (not only filter expressions): hover shows the short tip, - # a click opens the full explanation via dialogs.show_help. - help_btn = ttk.Button(cell, text=T("fields.match_help"), - style="Help.TButton", width=2, - command=lambda f=field: self._show_field_help(f)) - help_btn.pack(side="left", padx=(scaled(8), 0)) - add_tooltip(help_btn, field.tip) - self.helps[field.key] = help_btn + else: + self._add_help_button(cell, field) if field.hint: ttk.Label(cell, text=T(field.hint), style="Hint.TLabel").pack( side="left", padx=(scaled(8), 0)) @@ -346,6 +345,22 @@ def _show_match_help(self): dialogs.show_help(self.app.root, T("dialogs.match_help_title"), T("dialogs.match_help")) + def _add_help_button(self, parent, field): + """The "?" for any registry field that declares its own help sheet. + + Hover shows the short tip, a click opens the full explanation through + ``dialogs.show_help``. Shared by the checkbox and the entry paths so the + affordance cannot come out different depending on the widget kind. + """ + if not field.help_body: + return + help_btn = ttk.Button(parent, text=T("fields.match_help"), + style="Help.TButton", width=2, + command=lambda f=field: self._show_field_help(f)) + help_btn.pack(side="left", padx=(scaled(8), 0)) + add_tooltip(help_btn, field.tip) + self.helps[field.key] = help_btn + def _show_field_help(self, field): """Open the "?" help sheet a field declares (help_title / help_body).""" dialogs.show_help(self.app.root, T(field.help_title), T(field.help_body)) diff --git a/beantester/repro.py b/beantester/repro.py index c8c1cc0..1c7714c 100644 --- a/beantester/repro.py +++ b/beantester/repro.py @@ -22,7 +22,17 @@ def settings_to_cli(settings, seed=None, simulate=False): ("nat_timeout", "--nat-timeout"), ("rst_prob", "--rst-prob"), ("rst_cooldown", "--rst-cooldown"), ("flap_period", "--flap-period"), ("flap_down", "--flap-down"), - ("duration", "--duration")] + ("duration", "--duration"), + # The upload half. These go out whenever they DIFFER from the + # default, exactly like every line above - and `--asym` below + # decides whether the run reads them, so a command carrying a + # value without the switch reproduces a symmetric session, which + # is what that session was. + ("loss_up", "--loss-up"), ("corrupt_up", "--corrupt-up"), + ("dup_up", "--dup-up"), ("latency_up", "--latency-up"), + ("jitter_up", "--jitter-up"), + ("spike_prob_up", "--spike-prob-up"), + ("spike_ms_up", "--spike-ms-up")] for key, flag in numeric: if to_number(g(key)) != to_number(DEFAULT_SETTINGS[key]): args += [flag, number_string(g(key))] @@ -42,27 +52,32 @@ def settings_to_cli(settings, seed=None, simulate=False): block_port = setting_expression("block_port", g("block_port")) if block_port: args += ["--block-port", block_port] - # Whether the block answered or stayed silent decides what the application - # under test DID, so a run that leaves it out cannot be reproduced from its - # own command - which is the whole job of this line. - if g("block_reject"): - args += ["--block-reject"] - if g("lan_mode"): - args += ["--lan-mode"] - if g("ipv4_only"): - args += ["--ipv4-only"] - if g("ipv6_only"): - args += ["--ipv6-only"] - if g("internet_only"): - args += ["--internet-only"] - # START-only, and it changes what the session even SAW - a command without it - # re-runs a wider capture, so `packets` and every percentage derived from it - # describe a different run. It was missing until the guard below went looking - # (test_summary_repro_views.py::test_every_setting_with_a_flag_reaches_the_ - # reproduction_command); the repro REPORT has carried `narrowed` all along, - # which is why nobody noticed the command did not. - if g("narrow_filter"): - args += ["--narrow-filter"] + # The plain on/off switches, as a table rather than seven identical branches + # - the shape `settings_summary` took for the same reason, and the same + # reason it matters here: the complexity ratchet counts the branches, so the + # seventh switch would have cost something it should not. Order is the order + # they were emitted in, because a repro command is compared by eye against + # older ones. + # + # Each line still carries WHY it has to be in the command at all: + # * block_reject - whether the block answered or stayed silent decides what + # the application under test DID, so a run without it is not the same run; + # * asym - which half of the numbers above the session actually applied. + # Without it a command carrying seven upload values replays them as a + # symmetric run, and that difference is the point of the session; + # * narrow_filter - START-only, and it changes what the session even SAW, so + # `packets` and every percentage from it describe a different run. It was + # missing until the guard below went looking (test_summary_repro_views.py + # ::test_every_setting_with_a_flag_reaches_the_reproduction_command); the + # repro REPORT has carried `narrowed` all along, which is why nobody + # noticed the command did not. + for key, flag in (("block_reject", "--block-reject"), ("asym", "--asym"), + ("lan_mode", "--lan-mode"), ("ipv4_only", "--ipv4-only"), + ("ipv6_only", "--ipv6-only"), + ("internet_only", "--internet-only"), + ("narrow_filter", "--narrow-filter")): + if g(key): + args.append(flag) filt = g("filter") if filt and filt != "both": args += ["--filter", str(filt)] diff --git a/beantester/settings.py b/beantester/settings.py index c829ea0..74855f0 100644 --- a/beantester/settings.py +++ b/beantester/settings.py @@ -24,6 +24,12 @@ DEFAULT_SETTINGS = dict( loss=0, corrupt=0, dup=0, latency=0, jitter=0, down=0, up=0, loss_burst=0, # average packets lost in a row; 0 = spread evenly. See fields.py + # Asymmetry: off, and seven cold values. Off is what every profile, config + # file and repro command written before this existed decodes to, so the + # values below are unread until somebody asks for them. See fields.py. + asym=False, + latency_up=0, jitter_up=0, spike_prob_up=0, spike_ms_up=0, + loss_up=0, corrupt_up=0, dup_up=0, buffer=1000, # link buffer (ms) for the speed limit; 0 = unbounded. See fields.py filter="both", target="", dst_ip="", dst_port="", lan_mode=False, ipv4_only=False, ipv6_only=False, @@ -126,10 +132,21 @@ def armed_global_impairments(s): A narrow block - ``10.*``, ``172.16.0.0/12``, one port - is untouched and still bounds its own damage, which is what makes it not a narrowing. """ - armed = [key for key in F.GLOBALLY_IMPAIRING_KEYS - if F.is_active(FIELDS[key], s.get(key, DEFAULT_SETTINGS[key]))] + def live(key): + return F.is_active(FIELDS[key], s.get(key, DEFAULT_SETTINGS[key])) + + armed = [key for key in F.GLOBALLY_IMPAIRING_KEYS if live(key)] armed += [key for key in F.MATCHED_IMPAIRING_KEYS if _expression_covers_everything(key, s.get(key, DEFAULT_SETTINGS[key]))] + # ...and the third case the registry cannot state statically, for the same + # reason as the second: an upload loss of 50% damages every packet leaving + # this machine or nothing at all, and which of the two is decided by the + # switch named in ``Field.live_when``. Both halves have to be read - the + # value alone would warn about a run that touches nothing every time somebody + # turns asymmetry back off, and the switch alone would warn about a run whose + # seven values are all zero. + armed += [key for key in F.CONDITIONAL_IMPAIRING_KEYS + if live(FIELDS[key].live_when) and live(key)] return tuple(armed) @@ -632,6 +649,12 @@ def apply_settings(engine, s, log=lambda *_: None): g("latency"), g("jitter"), g("down"), g("up")) engine.set_buffer(g("buffer")) engine.set_loss_burst(g("loss_burst")) + # Inside the same batch as set_params: the two describe one link seen + # from its two ends, and a packet judged between them would get the new + # download values with the previous upload ones. + engine.set_asymmetry(bool(g("asym")), g("loss_up"), g("corrupt_up"), + g("dup_up"), g("latency_up"), g("jitter_up"), + g("spike_prob_up"), g("spike_ms_up")) if dest is not None: engine.set_dest(*dest) engine.set_ip_family(bool(g("ipv4_only")), bool(g("ipv6_only"))) diff --git a/beantester/summary.py b/beantester/summary.py index c0d83ec..41d4b98 100644 --- a/beantester/summary.py +++ b/beantester/summary.py @@ -5,6 +5,35 @@ from .utils import number_string, to_number +def _upload_parts(g, tr, num): + """The upload half of the description, or nothing when the link is symmetric. + + A function of its own so that ``settings_summary`` gains NO branch: it sits + one step below the complexity ceiling pinned in ``pyproject.toml``, where the + rule is to move code out rather than raise the number. + + 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. + """ + 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))) + 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"))) + if not inner: + return [tr("summary.asym_up_clean")] + return [tr("summary.asym_up", v=", ".join(inner))] + + def settings_summary(s, lang=None, prefix_key="summary.prefix"): """Return a readable description of the active impairments in the given language. @@ -52,6 +81,9 @@ def settings_summary(s, lang=None, prefix_key="summary.prefix"): parts.append(tr("summary.up", v=num("up"))) if to_number(g("spike_prob")) and to_number(g("spike_ms")): parts.append(tr("summary.spikes", ms=num("spike_ms"), p=num("spike_prob"))) + # Everything named so far describes DOWNLOADS once this is on, so the upload + # half is said right after them rather than at the end among the switches. + parts += _upload_parts(g, tr, num) if to_number(g("syn_drop")): parts.append(tr("summary.syn", v=num("syn_drop"))) if to_number(g("max_size")): diff --git a/lang/en.json b/lang/en.json index fffdfbb..dae3a20 100644 --- a/lang/en.json +++ b/lang/en.json @@ -85,6 +85,8 @@ "conns.up_seen": "up seen", "conns.yes": "yes", "dialogs.all_files": "All files", + "dialogs.asym_help": "Most connections are not the same in both directions. A home line downloads far faster than it uploads, and on mobile the difference can be larger still.\n\nLeave this off and one set of numbers applies to traffic in both directions, which is what this tool has always done.\n\nTurn it on and the fields higher up the page describe DOWNLOADS only, while the fields here describe UPLOADS. They start as copies of what you already typed, so switching it on changes nothing by itself - edit only the ones you want to differ.\n\nThis is worth reaching for when an app feels fine to browse with but struggles to send: a video call, a file upload, a game that reports your actions to a server.", + "dialogs.asym_help_title": "Different values for upload", "dialogs.buffer_help": "The buffer only matters when you set a download/upload limit. It's a queue where packets wait when they arrive faster than the limit lets through.\n\nBig buffer = long queue: packets wait longer (more delay), but almost nothing is lost.\nSmall buffer = short queue: little delay, but once it fills, packets start getting dropped.\n\nImportant: the speed you set is always delivered. The buffer only changes one thing - delay vs. loss.\n\nWhat to set:\n • You just want to cap the speed, delay doesn't matter → 1000-2000 ms.\n • You want to fake a slow, laggy link → enter how many ms of lag you want (e.g. 300 = slight, 2000 = very laggy). That is the most it can add: one small download may not fill the queue, several at once will.\n • You want to see packets being dropped → 100-300 ms.\n\nWatch the test length:\n • Short test (a few seconds): with a big buffer you may see no loss - the queue hasn't filled yet. Use a smaller buffer or test longer.\n • Longer test: loss shows up once the queue fills.\n\n0 = endless queue: nothing is dropped, but delay can grow forever. Usually not needed.\n\nDefault: 1000 ms.", "dialogs.buffer_help_title": "Buffer - how to choose", "dialogs.choose_columns": "Tick the columns this table should show. The choice is remembered for next time.", @@ -175,13 +177,16 @@ "events.queue_overflow": "Latency queue overflowed - the tool dropped packets of its own", "events.send_failed": "Injection failed - the tool could not put captured packets back on the wire", "events.stopped": "stopped", + "fields.asym": "Use separate values for upload", "fields.block_reject": "Refuse blocked connections", "fields.buffer": "Buffer:", "fields.buffer_hint": "0 = unlimited", "fields.corruption": "Corruption:", + "fields.corruption_up": "Corruption up:", "fields.destination": "Destination (IP / port)", "fields.download": "Download:", "fields.duplication": "Duplication:", + "fields.duplication_up": "Duplication up:", "fields.duration": "Run time:", "fields.duration_hint": "0 = until stopped", "fields.expects_number": "a number", @@ -193,12 +198,15 @@ "fields.ipv4_only": "IPv4 addresses only", "fields.ipv6_only": "IPv6 addresses only", "fields.jitter": "Jitter:", + "fields.jitter_up": "Jitter up:", "fields.lan_mode": "LAN mode (local network only, no internet)", "fields.latency": "Latency:", + "fields.latency_up": "Latency up:", "fields.locked_running": "Locked while a session runs (STOP unlocks it).", "fields.loop": "Loop", "fields.loss": "Loss:", "fields.loss_burst": "Losses in a row (burst):", + "fields.loss_up": "Loss up:", "fields.match_help": "?", "fields.max_size": "Max size (MTU):", "fields.narrow_filter": "Capture only the targeted traffic", @@ -222,7 +230,9 @@ "fields.seed_hint": "(empty = random)", "fields.spike": "Latency spike:", "fields.spike_ms": "Spike size:", + "fields.spike_ms_up": "Spike size up:", "fields.spike_prob": "Spike chance:", + "fields.spike_prob_up": "Spike chance up:", "fields.syn_drop": "Dropped TCP SYN:", "fields.target_dest": "Target dest", "fields.target_example": "e.g. chrome.exe, 12345, re:^fire", @@ -241,6 +251,7 @@ "filters.tcp": "TCP only", "filters.udp": "UDP only", "frames.advanced": "Advanced (NAT / connections)", + "frames.asymmetry": "Asymmetry (different values for upload)", "frames.block": "Blocking (firewall)", "frames.destination": "Destination targeting", "frames.event_log": "Event log (timestamped)", @@ -430,6 +441,8 @@ "stats.syn_dropped": "SYN dropped", "stats.upload": "Upload", "summary.any_ip": "any IP", + "summary.asym_up": "upload: {v}", + "summary.asym_up_clean": "upload: untouched", "summary.block": "blocking {v}", "summary.corrupt": "{v}% corruption", "summary.dest": "dest only {v}", @@ -463,6 +476,7 @@ "tables.no_events_yet": "No events yet. They appear here as the session runs.", "tips.about": "Version, author, licence and the third-party components this program ships with.", "tips.apply": "Applies setting changes WITHOUT stopping (live). The traffic filter can only be changed by restarting.", + "tips.asym": "Real home and mobile links are rarely the same speed and quality in both directions. Tick this to give uploads their own numbers, and the values above then apply to downloads only. The boxes below start as copies of what you already set, so nothing changes until you edit them.", "tips.avg_rate": "Average throughput since start = total MB / duration.", "tips.block": "Block (drop) all traffic to matching destinations. IP and Port each take a comma-separated list. Blocking triggers on IP OR Port (an empty field is ignored). Supports ranges, CIDR, wildcards and ! to exclude. Other traffic is unaffected.", "tips.block_reject": "A blocked connection is refused instead of going unanswered, so the program you are testing reports 'connection refused' in about two seconds - the same as a port that is really closed - rather than waiting out its own timeout. Only connections this computer starts, and only TCP: anything using UDP, and connections coming in from outside, are still blocked in silence.", @@ -498,6 +512,7 @@ "tips.copy_counters": "Copies every counter on this tab to the clipboard, with its caption and unit, exactly as it reads here.", "tips.copy_session": "Copies every line of this panel to the clipboard, exactly as it reads here - including the computer name and its addresses.", "tips.corrupt": "Percent of packets with one flipped data bit - tests resilience to corrupted data.", + "tips.corrupt_up": "Percent of SENT packets that arrive with damaged contents. Only used while the box above is ticked.", "tips.data_down": "How much data actually passed downstream (download) since start - real usage. Packets dropped by loss or a speed limit are not counted here.", "tips.data_total": "Sum of downloaded and uploaded data this session.", "tips.data_up": "How much data actually passed upstream (upload) since start.", @@ -507,6 +522,7 @@ "tips.down_limit": "Max throughput of INCOMING traffic (download) in KB/s. 0 = unlimited. Note: ping uses tiny packets, so a speed limit barely affects it.", "tips.driver_wait": "The longest a packet had ALREADY been waiting inside WinDivert when this tool received it. It is measured, not estimated - the driver stamps every packet with a capture time. On an idle machine this is a fraction of a millisecond. If it grows, the tool is adding delay that appears in no other counter, because it happens in the driver's queue before ours.", "tips.dup": "Percent of packets sent twice - a real symptom of a poor network.", + "tips.dup_up": "Percent of SENT packets that arrive twice. Only used while the box above is ticked.", "tips.duration": "Stop the session automatically after this many seconds. 0 = run until you press STOP. Applied at START only (like the traffic filter).", "tips.eff_loss": "How much of the traffic you aimed at THIS TOOL broke, counting every impairment: loss, speed-limit drops, blocking, link outages, connection resets and the rest. With a target set, only the target's traffic counts, so other applications cannot dilute the number. It measures damage done here, on this machine - a packet lost somewhere out in the network never arrives here, so nothing here can count it (that is what your application's own figures, or ping's \"Lost\", are for). Packets the tool dropped because it was overloaded are not in here either. They have their own counters, \"Buffer overflow\" and \"Dropped at stop\".", "tips.eff_seed": "The exact seed used in this session. Put it in the Seed field and run again to get the same randomization - the key to reproducing a bug.", @@ -522,13 +538,16 @@ "tips.ipv4_only": "Impairments touch IPv4 traffic only. IPv6 keeps flowing normally: it is not blocked and not slowed, just left alone. This applies with the IP field empty too, which means all addresses.", "tips.ipv6_only": "Impairments touch IPv6 traffic only. IPv4 keeps flowing normally: it is not blocked and not slowed, just left alone. This applies with the IP field empty too, which means all addresses.", "tips.jitter": "Random delay variation (+/- ms), drawn separately for every packet. Ping starts to jump instead of being steady. It also lightly reorders packets. The request and the reply each get their own draw, so the wobble on ping is wider than this number - about 1.4x usually, up to 2x at the extremes.", + "tips.jitter_up": "How much the upload delay wanders up and down, in milliseconds. Only used while the box above is ticked.", "tips.lan_mode": "Simulates a network with no internet access: traffic to/from public addresses is dropped, while the local network (LAN: 10.x, 192.168.x, 172.16-31.x, loopback) works. Tests how the app behaves when the internet is down but the intranet is up.", "tips.language": "Interface language. Switching rebuilds the UI but keeps the current session and settings. Locked while running.", "tips.latency": "How many milliseconds of delay to add to every packet. With the default two-way traffic filter both the request and the reply are delayed, so ping rises by about TWICE this number: 100 ms here means roughly +200 ms of ping.", + "tips.latency_up": "Delay added to packets your machine SENDS, in milliseconds. Only used while the box above is ticked.", "tips.load_config": "Load all settings from a JSON file (same format as CLI: --config).", "tips.log_lines": "How many log lines to keep in the strip at the bottom. Older lines are dropped once the limit is reached.", "tips.loss": "Percent of packets that vanish without a trace. Even 5% feels like a dropping connection.", "tips.loss_burst": "Makes the loss set beside it arrive in runs instead of one packet at a time, and the number is how many packets are lost in a row on average. A run of losses hurts a connection far more than the same total spread evenly, so keep it low unless you are testing how an application recovers.", + "tips.loss_up": "Percent of SENT packets that vanish. Only used while the box above is ticked.", "tips.mark_bug": "Inserts a timestamped marker into the log - click exactly when you see the bug.", "tips.match_syntax": "Filter syntax (the same in every field):\n 80,443 a list - any of them\n 1000-2000 a range, both ends included\n >1024 <=80 comparisons: > < >= <=\n !53 exclusion - everything except 53\n 8* chrome* wildcard (* and ?)\n re:^chrome regular expression\nIP also takes CIDR (192.168.1.0/24) and IPv6.\nTerms are combined: any positive term matches, every ! term excludes. A field with only exclusions means 'everything except those'. Empty = everything.", "tips.mtu": "Drop packets larger than the given number of bytes. Reproduces the 'MTU black hole' common in tunnels/VPN/behind NAT: small packets pass, large ones vanish. 0 = off.", @@ -557,6 +576,7 @@ "tips.settings": "App settings: interface language and how many rows the tables show.", "tips.show_control_search": "Shows the \"Search\" box at the top of the Control page. With it off the box is gone and Ctrl+F takes you to the search box in the Connections tab instead.", "tips.spike": "Occasional ping spikes: with the given probability (%) add extra delay (ms) to a single packet. A spiked packet arrives after ones sent later than it, so this changes packet order without making every delay wobble the way Jitter does.", + "tips.spike_up": "An occasional much longer delay on uploads: how often it happens, and how much it adds. Only used while the box above is ticked.", "tips.start": "Turns traffic modification on/off. Picking a preset or changing fields does nothing on its own - only START begins impairing. Requires running as administrator.", "tips.stat_block": "Packets dropped by a block (firewall) rule.", "tips.stat_corrupted": "Packets whose payload had a data bit flipped. Packets with no payload (e.g. bare ACKs) can't be corrupted - they pass through and aren't counted here, so this can be below the set percentage.", diff --git a/lang/pl.json b/lang/pl.json index f7a92fd..6a9e538 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -85,6 +85,8 @@ "conns.up_seen": "wys. widz.", "conns.yes": "tak", "dialogs.all_files": "Wszystkie pliki", + "dialogs.asym_help": "Większość łączy nie jest taka sama w obie strony. Łącze domowe pobiera dużo szybciej, niż wysyła, a w komórce różnica bywa jeszcze większa.\n\nZostaw wyłączone, a jeden komplet liczb dotyczy ruchu w obie strony - tak ten program działał zawsze.\n\nWłącz, a pola wyżej na stronie opisują tylko POBIERANIE, a pola tutaj - WYSYŁANIE. Startują jako kopie tego, co już wpisałeś, więc samo włączenie niczego nie zmienia: popraw tylko te, które mają się różnić.\n\nWarto po to sięgnąć, gdy aplikacja dobrze się przegląda, ale słabo wysyła: rozmowa wideo, wysyłka pliku, gra raportująca Twoje ruchy na serwer.", + "dialogs.asym_help_title": "Inne wartości dla wysyłania", "dialogs.buffer_help": "Bufor działa tylko, gdy ustawisz limit pobierania/wysyłania. To kolejka, w której pakiety czekają, gdy przychodzą szybciej, niż przepuszcza limit.\n\nDuży bufor = długa kolejka: pakiety czekają dłużej (większe opóźnienie), ale prawie nic nie ginie.\nMały bufor = krótka kolejka: małe opóźnienie, ale gdy się zapełni, pakiety zaczynają ginąć.\n\nWażne: ustawiona prędkość zawsze jest dotrzymana. Bufor zmienia tylko jedno - opóźnienie kontra straty.\n\nCo ustawić:\n • Chcesz tylko ograniczyć prędkość, opóźnienie nieważne → 1000-2000 ms.\n • Chcesz udawać wolne, „mulące” łącze → wpisz tyle ms, ile ma mulić (np. 300 = lekko, 2000 = mocno). Tyle najwyżej doda: jedno małe pobieranie może nie zapełnić kolejki, kilka naraz zapełni.\n • Chcesz zobaczyć gubienie pakietów → 100-300 ms.\n\nUwaga na długość testu:\n • Krótki test (parę sekund): przy dużym buforze możesz nie zobaczyć strat - kolejka nie zdążyła się zapełnić. Daj mniejszy bufor albo testuj dłużej.\n • Dłuższy test: straty i tak się pojawią, gdy kolejka się zapełni.\n\n0 = kolejka bez końca: nic nie ginie, ale opóźnienie może rosnąć w nieskończoność. Zwykle niepotrzebne.\n\nDomyślnie: 1000 ms.", "dialogs.buffer_help_title": "Bufor - jak dobrać", "dialogs.choose_columns": "Zaznacz kolumny, ktore ma pokazywac ta tabela. Wybor zostanie zapamietany.", @@ -175,13 +177,16 @@ "events.queue_overflow": "Przepełnienie kolejki opóźnienia - narzędzie samo gubiło pakiety", "events.send_failed": "Wstrzykiwanie się nie powiodło - narzędzie nie odesłało przechwyconych pakietów do sieci", "events.stopped": "zatrzymano", + "fields.asym": "Osobne wartości dla wysyłania", "fields.block_reject": "Odmawiaj zablokowanym połączeniom", "fields.buffer": "Bufor:", "fields.buffer_hint": "0 = bez limitu", "fields.corruption": "Uszkodzenie:", + "fields.corruption_up": "Uszkodzenie w górę:", "fields.destination": "Cel (IP / port)", "fields.download": "Pobieranie:", "fields.duplication": "Duplikacja:", + "fields.duplication_up": "Duplikacja w górę:", "fields.duration": "Czas trwania:", "fields.duration_hint": "0 = do zatrzymania", "fields.expects_number": "liczby", @@ -193,12 +198,15 @@ "fields.ipv4_only": "Tylko adresy IPv4", "fields.ipv6_only": "Tylko adresy IPv6", "fields.jitter": "Jitter:", + "fields.jitter_up": "Jitter w górę:", "fields.lan_mode": "Tryb LAN (tylko sieć lokalna, bez internetu)", "fields.latency": "Latencja:", + "fields.latency_up": "Latencja w górę:", "fields.locked_running": "Zablokowane w trakcie sesji (STOP odblokuje).", "fields.loop": "Pętla", "fields.loss": "Utrata:", "fields.loss_burst": "Straty pod rząd (burst):", + "fields.loss_up": "Utrata w górę:", "fields.match_help": "?", "fields.max_size": "Maks. rozmiar (MTU):", "fields.narrow_filter": "Przechwytuj tylko ruch celu", @@ -222,7 +230,9 @@ "fields.seed_hint": "(puste = losowo)", "fields.spike": "Skok latencji:", "fields.spike_ms": "Wielkość skoku:", + "fields.spike_ms_up": "Wielkość skoku w górę:", "fields.spike_prob": "Szansa skoku:", + "fields.spike_prob_up": "Szansa skoku w górę:", "fields.syn_drop": "Gubione TCP SYN:", "fields.target_dest": "Celuj w cel", "fields.target_example": "np. chrome.exe, 12345, re:^fire", @@ -241,6 +251,7 @@ "filters.tcp": "Tylko TCP", "filters.udp": "Tylko UDP", "frames.advanced": "Zaawansowane (NAT / połączenia)", + "frames.asymmetry": "Asymetria (inne wartości dla wysyłania)", "frames.block": "Blokowanie (firewall)", "frames.destination": "Celuj w adres docelowy", "frames.event_log": "Dziennik zdarzeń (ze znacznikami czasu)", @@ -430,6 +441,8 @@ "stats.syn_dropped": "SYN odrzucone", "stats.upload": "Wysyłanie", "summary.any_ip": "dowolne IP", + "summary.asym_up": "wysyłanie: {v}", + "summary.asym_up_clean": "wysyłanie: bez zmian", "summary.block": "blokada {v}", "summary.corrupt": "{v}% uszkodzeń", "summary.dest": "tylko cel {v}", @@ -463,6 +476,7 @@ "tables.no_events_yet": "Nie ma jeszcze żadnych zdarzeń. Pojawią się w trakcie sesji.", "tips.about": "Wersja, autor, licencja i składniki firm trzecich, które program dostarcza.", "tips.apply": "Nanosi zmiany ustawień BEZ zatrzymywania (działa w trakcie). Filtr ruchu zmienisz tylko po restarcie.", + "tips.asym": "Prawdziwe łącza domowe i komórkowe rzadko są tak samo szybkie i dobre w obie strony. Zaznacz, żeby wysyłanie miało własne liczby, a wartości powyżej dotyczyły tylko pobierania. Pola poniżej startują jako kopie tego, co już ustawiłeś, więc dopóki ich nie zmienisz, nic się nie dzieje.", "tips.avg_rate": "Średnia przepustowość od startu = razem MB / czas trwania.", "tips.block": "Blokuj (odrzucaj) cały ruch do pasujących celów. Pola IP i Port przyjmują listę po przecinku. Blokada działa na IP LUB Port (puste pole jest pomijane). Obsługuje zakresy, CIDR, wildcardy i ! do wykluczenia. Pozostały ruch bez zmian.", "tips.block_reject": "Zablokowane połączenie dostaje odmowę zamiast ciszy, więc testowany program zgłasza „odmowa połączenia” po około dwóch sekundach - tak samo jak przy naprawdę zamkniętym porcie - zamiast czekać, aż sam się podda. Działa tylko dla połączeń wychodzących z tego komputera i tylko dla TCP: ruch po UDP i połączenia przychodzące z zewnątrz są nadal blokowane po cichu.", @@ -498,6 +512,7 @@ "tips.copy_counters": "Kopiuje do schowka wszystkie liczniki z tej zakładki, z podpisem i jednostką, dokładnie tak, jak tu wyglądają.", "tips.copy_session": "Kopiuje do schowka każdy wiersz tego panelu, dokładnie tak, jak tu wygląda - razem z nazwą komputera i jego adresami.", "tips.corrupt": "Procent pakietów z przekłamanym jednym bitem danych - test odporności na uszkodzone dane.", + "tips.corrupt_up": "Procent WYSYŁANYCH pakietów, które docierają z uszkodzoną treścią. Używane tylko przy zaznaczonym polu powyżej.", "tips.data_down": "Ile danych faktycznie przeszło w dół (pobieranie) od startu - realne zużycie. Pakiety porzucone przez utratę albo limit prędkości nie są tu liczone.", "tips.data_total": "Suma pobranych i wysłanych danych w tej sesji.", "tips.data_up": "Ile danych faktycznie przeszło w górę (wysyłanie) od startu.", @@ -507,6 +522,7 @@ "tips.down_limit": "Maks. przepustowość ruchu PRZYCHODZĄCEGO (pobieranie) w KB/s. 0 = bez limitu. Uwaga: ping to małe pakiety, więc limit prędkości prawie go nie zmienia.", "tips.driver_wait": "Najdłuższy czas, jaki pakiet JUŻ przeczekał wewnątrz WinDiverta, zanim narzędzie go dostało. To pomiar, nie oszacowanie - sterownik stempluje każdy pakiet czasem przechwycenia. Na spokojnej maszynie to ułamek milisekundy. Gdy rośnie, narzędzie dokłada opóźnienie, którego nie widać w żadnym innym liczniku, bo powstaje w kolejce sterownika przed naszą.", "tips.dup": "Procent pakietów wysyłanych podwójnie - realny objaw kiepskiej sieci.", + "tips.dup_up": "Procent WYSYŁANYCH pakietów, które docierają dwa razy. Używane tylko przy zaznaczonym polu powyżej.", "tips.duration": "Zatrzymaj sesję automatycznie po tylu sekundach. 0 = działa aż do naciśnięcia STOP. Stosowane tylko przy STARCIE (tak jak filtr ruchu).", "tips.eff_loss": "Jaką część ruchu, w który celujesz, zepsuło TO NARZĘDZIE - licząc każde zakłócenie: stratę, porzucenia z limitu prędkości, blokadę, przerwy w łączu, zrywanie połączeń i resztę. Gdy ustawisz cel, liczy się tylko jego ruch, więc inne aplikacje nie rozwadniają tej liczby. To miara szkody wyrządzonej tutaj, na tej maszynie - pakiet zgubiony gdzieś w sieci nigdy tu nie dociera, więc nic go tu nie policzy (od tego są liczniki samej aplikacji albo „Lost” w wyniku ping-a). Pakiety porzucone przez przeciążone narzędzie też tu nie wchodzą - mają własne liczniki: „Bufor przepełn.” i „Porzuc. przy stopie”.", "tips.eff_seed": "Konkretny seed użyty w tej sesji. Wpisz go w pole Seed i uruchom ponownie, aby dostać te same losowania - klucz do odtworzenia błędu.", @@ -522,13 +538,16 @@ "tips.ipv4_only": "Zakłócenia dotykają tylko ruchu po IPv4. Ruch IPv6 płynie normalnie: nie jest blokowany ani spowalniany, po prostu zostaje bez zmian. Działa też przy pustym polu IP, czyli dla wszystkich adresów.", "tips.ipv6_only": "Zakłócenia dotykają tylko ruchu po IPv6. Ruch IPv4 płynie normalnie: nie jest blokowany ani spowalniany, po prostu zostaje bez zmian. Działa też przy pustym polu IP, czyli dla wszystkich adresów.", "tips.jitter": "Losowe wahanie opóźnienia (+/- ms), losowane osobno dla każdego pakietu. Ping zaczyna skakać zamiast być stały. Powoduje też lekkie mieszanie kolejności pakietów. Zapytanie i odpowiedź losują niezależnie, więc wahania pingu są szersze niż ta liczba - zwykle około 1,4x, w skrajności 2x.", + "tips.jitter_up": "O ile waha się opóźnienie wysyłania, w milisekundach. Używane tylko przy zaznaczonym polu powyżej.", "tips.lan_mode": "Symuluje sieć bez dostępu do internetu: ruch do/od adresów publicznych jest odrzucany, a sieć lokalna (LAN: 10.x, 192.168.x, 172.16-31.x, loopback) działa. Test zachowania aplikacji, gdy internet jest niedostępny, a intranet tak.", "tips.language": "Język interfejsu. Przełączenie przebudowuje UI, ale zachowuje bieżącą sesję i ustawienia. Zablokowane w trakcie działania.", "tips.latency": "Ile milisekund opóźnienia doliczyć do każdego pakietu. Przy domyślnym filtrze „w obie strony” opóźniane są i zapytanie, i odpowiedź, więc ping rośnie o mniej więcej DWA RAZY tyle: 100 ms tutaj to około +200 ms pingu.", + "tips.latency_up": "Opóźnienie doliczane do pakietów, które komputer WYSYŁA, w milisekundach. Używane tylko przy zaznaczonym polu powyżej.", "tips.load_config": "Wczytaj wszystkie ustawienia z pliku JSON (ten sam format co w CLI: --config).", "tips.log_lines": "Ile linii logu trzymać w pasku na dole. Starsze linie są usuwane po osiągnięciu limitu.", "tips.loss": "Procent pakietów, które znikają bez śladu. Już 5% to odczuwalnie zrywająca się sieć.", "tips.loss_burst": "Sprawia, że strata ustawiona obok pojawia się seriami, a nie po jednym pakiecie, a liczba mówi, ile pakietów ginie pod rząd średnio. Seria strat szkodzi połączeniu dużo bardziej niż ta sama liczba rozłożona równomiernie, więc trzymaj ją nisko, chyba że sprawdzasz, jak aplikacja się podnosi.", + "tips.loss_up": "Procent WYSYŁANYCH pakietów, które znikają. Używane tylko przy zaznaczonym polu powyżej.", "tips.mark_bug": "Wstawia do dziennika znacznik z czasem - kliknij dokładnie wtedy, gdy zobaczysz błąd.", "tips.match_syntax": "Składnia filtrów (taka sama w każdym polu):\n 80,443 lista - dowolna z wartości\n 1000-2000 zakres, oba końce włącznie\n >1024 <=80 porównania: > < >= <=\n !53 wykluczenie - wszystko oprócz 53\n 8* chrome* wildcard (* i ?)\n re:^chrome wyrażenie regularne\nIP przyjmuje też CIDR (192.168.1.0/24) oraz IPv6.\nCzłony łączą się: pasuje dowolny człon pozytywny, każdy człon z ! wyklucza. Pole z samymi wykluczeniami znaczy 'wszystko oprócz nich'. Puste = wszystko.", "tips.mtu": "Gub pakiety większe niż podana liczba bajtów. Odwzorowuje 'czarną dziurę MTU' typową w tunelach/VPN/za NAT: małe pakiety przechodzą, duże znikają. 0 = wyłączone.", @@ -557,6 +576,7 @@ "tips.settings": "Ustawienia aplikacji: język interfejsu i ile wierszy pokazują tabele.", "tips.show_control_search": "Pokazuje pole „Szukaj” u góry strony Sterowanie. Po wyłączeniu pole znika, a Ctrl+F przenosi do wyszukiwarki w zakładce Połączenia.", "tips.spike": "Sporadyczne skoki pingu: z podanym prawdopodobieństwem (%) doklej dodatkowe opóźnienie (ms) do pojedynczego pakietu. Pakiet ze skokiem dociera po tych wysłanych później niż on, więc zmienia to kolejność pakietów bez rozchwiania każdego opóźnienia, jak robi to Jitter.", + "tips.spike_up": "Rzadkie, znacznie dłuższe opóźnienie przy wysyłaniu: jak często się zdarza i ile dokłada. Używane tylko przy zaznaczonym polu powyżej.", "tips.start": "Włącza/wyłącza modyfikowanie ruchu. Wybór presetu lub zmiana pól sama nic nie robi - dopiero START uruchamia zakłócenia. Wymaga uruchomienia jako administrator.", "tips.stat_block": "Pakiety odrzucone przez regułę blokady (firewall).", "tips.stat_corrupted": "Pakiety, w których przekłamano bit danych. Pakietów bez danych (np. samych ACK) nie da się uszkodzić - przechodzą i nie są tu liczone, więc liczba bywa niższa niż ustawiony procent.", diff --git a/lang/zh.json b/lang/zh.json index 9398c5e..a197fab 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -85,6 +85,8 @@ "conns.up_seen": "观测上传", "conns.yes": "是", "dialogs.all_files": "所有文件", + "dialogs.asym_help": "大多数线路在两个方向上并不相同。家庭宽带的下载远快于上传,在移动网络上差距可能更大。\n\n保持关闭时,一组数值同时作用于两个方向的流量,这也是本工具一直以来的行为。\n\n打开后,页面上方的各项只描述下载,这里的各项描述上传。它们会先复制你已经填写的内容,因此仅仅打开开关不会改变任何东西:只修改你希望不同的那几项。\n\n当一个应用浏览起来正常、发送时却很吃力,就值得用它:视频通话、上传文件,或者需要把你的操作上报给服务器的游戏。", + "dialogs.asym_help_title": "上传使用不同的数值", "dialogs.buffer_help": "缓冲区只在设置下载或上传限速时生效。它相当于一个队列:当数据包到达速度超过限速允许的速度时,会先在这里等待。\n\n大缓冲区 = 长队列:数据包等待更久(延迟更高),但几乎不会丢失。\n小缓冲区 = 短队列:延迟较低,但队列一满就会开始丢包。\n\n重要:你设置的速度上限始终不变。缓冲区只改变一件事:延迟与丢包之间的取舍。\n\n建议设置:\n • 只想限制速度,不在意延迟 → 1000-2000 ms。\n • 想模拟缓慢、卡顿的链路 → 填入希望产生的排队延迟毫秒数(例如 300 = 轻微,2000 = 非常卡)。这是最多可增加的延迟。一次小下载可能填不满队列,同时进行多个传输时更容易填满。\n • 想观察数据包被丢弃 → 100-300 ms。\n\n还要注意测试时长:\n • 短测试(几秒):大缓冲区可能还没填满,因此看不到丢包。请减小缓冲区或延长测试。\n • 长测试:队列填满后会出现丢包。\n\n0 = 无限队列:不会因缓冲区满而丢包,但延迟可能无限增长,通常不建议使用。\n\n默认值:1000 ms。", "dialogs.buffer_help_title": "缓冲区设置指南", "dialogs.choose_columns": "勾选此表格要显示的列。你的选择会在下次启动时保留。", @@ -175,13 +177,16 @@ "events.queue_overflow": "延迟队列溢出,本工具自行丢弃了数据包", "events.send_failed": "注入失败,本工具无法将捕获的数据包重新发送到网络", "events.stopped": "已停止", + "fields.asym": "上传使用单独的数值", "fields.block_reject": "对被阻断的连接回复拒绝", "fields.buffer": "缓冲区:", "fields.buffer_hint": "0 = 无限制", "fields.corruption": "数据损坏:", + "fields.corruption_up": "上传数据损坏:", "fields.destination": "目标地址(IP / 端口)", "fields.download": "下载:", "fields.duplication": "重复包:", + "fields.duplication_up": "上传重复包:", "fields.duration": "运行时长:", "fields.duration_hint": "0 = 直到手动停止", "fields.expects_number": "一个数字", @@ -193,12 +198,15 @@ "fields.ipv4_only": "仅 IPv4 地址", "fields.ipv6_only": "仅 IPv6 地址", "fields.jitter": "抖动:", + "fields.jitter_up": "上传抖动:", "fields.lan_mode": "局域网模式(仅本地网络,不访问互联网)", "fields.latency": "延迟:", + "fields.latency_up": "上传延迟:", "fields.locked_running": "会话运行期间已锁定(停止后解锁)。", "fields.loop": "循环", "fields.loss": "丢包:", "fields.loss_burst": "连续丢包(burst):", + "fields.loss_up": "上传丢包:", "fields.match_help": "?", "fields.max_size": "最大大小(MTU):", "fields.narrow_filter": "仅捕获目标流量", @@ -222,7 +230,9 @@ "fields.seed_hint": "(留空 = 随机)", "fields.spike": "延迟尖峰:", "fields.spike_ms": "尖峰幅度:", + "fields.spike_ms_up": "上传尖峰幅度:", "fields.spike_prob": "尖峰概率:", + "fields.spike_prob_up": "上传尖峰概率:", "fields.syn_drop": "丢弃 TCP SYN:", "fields.target_dest": "目标地址", "fields.target_example": "例如 chrome.exe、12345、re:^fire", @@ -241,6 +251,7 @@ "filters.tcp": "仅 TCP", "filters.udp": "仅 UDP", "frames.advanced": "高级(NAT / 连接)", + "frames.asymmetry": "非对称(上传使用不同数值)", "frames.block": "阻断(防火墙)", "frames.destination": "目标地址筛选", "frames.event_log": "事件日志(带时间戳)", @@ -430,6 +441,8 @@ "stats.syn_dropped": "SYN 已丢弃", "stats.upload": "上传", "summary.any_ip": "任意 IP", + "summary.asym_up": "上传:{v}", + "summary.asym_up_clean": "上传:不做处理", "summary.block": "阻断 {v}", "summary.corrupt": "{v}% 数据损坏", "summary.dest": "仅目标地址 {v}", @@ -463,6 +476,7 @@ "tables.no_events_yet": "尚无事件。会话运行时,事件会显示在这里。", "tips.about": "查看版本、作者、许可证以及本程序附带的第三方组件。", "tips.apply": "无需停止会话即可实时应用设置更改。流量过滤器只能通过重新启动会话来更改。", + "tips.asym": "真实的家庭宽带和移动网络很少在两个方向上速度和质量相同。勾选后上传将使用自己的数值,上方的数值则只作用于下载。下方各项会先复制你已填写的内容,因此在你修改之前不会有任何变化。", "tips.avg_rate": "从会话开始至今的平均吞吐量 = 总 MB ÷ 持续时间。", "tips.block": "阻断(丢弃)所有匹配目标地址的流量。IP 和端口字段都支持逗号分隔的列表。阻断条件按 IP 或端口匹配(空字段会被忽略),并支持范围、CIDR、通配符和“!”排除。其他流量不受影响。", "tips.block_reject": "被阻断的连接会收到拒绝响应,而不是没有任何回应:被测程序大约两秒后就会报告“连接被拒绝”,和真正关闭的端口一样,而不必一直等到自己超时。仅适用于本机发起的连接,并且仅限 TCP:UDP 流量和从外部进入的连接仍然会被静默阻断。", @@ -498,6 +512,7 @@ "tips.copy_counters": "把此标签页上的所有计数器及其标题和单位按当前显示内容复制到剪贴板。", "tips.copy_session": "把此面板的每一行按当前显示内容复制到剪贴板,包括计算机名及其地址。", "tips.corrupt": "随机翻转指定百分比数据包中的一个数据位,用于测试应用对损坏数据的容错能力。", + "tips.corrupt_up": "发送的数据包中内容被损坏的百分比。仅在勾选上方选项时生效。", "tips.data_down": "自会话开始以来实际通过的下行(下载)数据量,即真实用量。因丢包或限速而被丢弃的数据不计入这里。", "tips.data_total": "本次会话下载与上传数据量之和。", "tips.data_up": "自会话开始以来实际通过的上行(上传)数据量。", @@ -507,6 +522,7 @@ "tips.down_limit": "入站流量(下载)的最大吞吐量,单位为 KB/s。0 = 不限制。注意:Ping 数据包很小,因此限速对 Ping 的影响通常很轻微。", "tips.driver_wait": "本工具收到数据包时,该数据包已经在 WinDivert 内等待的最长时间。此值由驱动为每个数据包记录的捕获时间计算,是实测值而非估算值。机器空闲时通常不到 1 毫秒。若持续增大,说明驱动队列在本工具之前就引入了额外延迟,而该延迟不会出现在其他计数器中。", "tips.dup": "将指定百分比的数据包发送两次,用于模拟较差网络中真实可能出现的重复包。", + "tips.dup_up": "发送的数据包中被重复送达的百分比。仅在勾选上方选项时生效。", "tips.duration": "达到指定秒数后自动停止会话。0 = 一直运行到你点击“停止”。此设置只在开始会话时应用,与流量过滤器相同。", "tips.eff_loss": "你要求本工具处理的流量中,实际被破坏的比例。它会合并计算所有弱网效果:丢包、限速丢弃、阻断、链路中断、连接重置等。设置目标后,只统计目标流量,其他应用不会稀释该比例。此指标只衡量本机上由本工具造成的影响。网络远端丢失的数据包根本到不了本机,因此无法在这里计数(应查看应用自身统计或 Ping 的“丢失”)。本工具因过载而自行丢弃的数据包也不包含在内,它们分别记录在“缓冲区溢出”和“停止时丢弃”计数器中。", "tips.eff_seed": "本次会话实际使用的精确随机种子。把它填回“随机种子”字段后重新运行,即可得到相同的随机结果,这是稳定复现故障的关键。", @@ -522,13 +538,16 @@ "tips.ipv4_only": "只对 IPv4 流量施加弱网效果。IPv6 流量照常通过,既不会被阻断也不会被限速,只是保持原样。即使 IP 字段为空(即所有地址)也同样生效。", "tips.ipv6_only": "只对 IPv6 流量施加弱网效果。IPv4 流量照常通过,既不会被阻断也不会被限速,只是保持原样。即使 IP 字段为空(即所有地址)也同样生效。", "tips.jitter": "为每个数据包分别随机增加或减少指定毫秒数的延迟,使 Ping 不再稳定并产生轻微乱序。请求和响应会各自独立随机,因此 Ping 的实际波动通常约为此数值的 1.4 倍,极端情况下可达到 2 倍。", + "tips.jitter_up": "上传延迟的波动幅度,单位毫秒。仅在勾选上方选项时生效。", "tips.lan_mode": "模拟无法访问互联网但局域网仍可用的环境:与公共地址往返的流量会被丢弃,而局域网地址(10.x、192.168.x、172.16-31.x、环回地址)仍可通信。可用于测试应用在断网但内网正常时的表现。", "tips.language": "界面语言。切换后会重建界面,但当前会话和设置都会保留。会话运行期间无法切换。", "tips.latency": "为每个数据包增加指定毫秒数的延迟。默认使用双向流量过滤器,因此请求和响应都会被延迟,Ping 通常会增加约两倍:这里填 100 ms,Ping 大约增加 200 ms。", + "tips.latency_up": "为本机发送的数据包增加的延迟,单位毫秒。仅在勾选上方选项时生效。", "tips.load_config": "从 JSON 文件加载全部设置,格式与 CLI 的 --config 相同。", "tips.log_lines": "底部日志区域最多保留的行数。达到上限后,更旧的行会被移除。", "tips.loss": "让指定百分比的数据包无声消失。即使只有 5%,连接也会明显不稳定。", "tips.loss_burst": "让旁边设置的丢包成串出现,而不是一个一个地丢,数值表示平均连续丢失多少个数据包。同样的丢包总量,成串出现对连接的影响远大于均匀分布,因此除非你要测试应用的恢复能力,否则请保持较小的数值。", + "tips.loss_up": "发送的数据包中无声消失的百分比。仅在勾选上方选项时生效。", "tips.mark_bug": "在日志中插入带时间戳的标记。看到故障的准确时刻立即点击。", "tips.match_syntax": "过滤语法(所有字段通用):\n 80,443 列表,匹配其中任意一个\n 1000-2000 范围,包含两端\n >1024 <=80 比较:> < >= <=\n !53 排除,匹配除 53 之外的全部\n 8* chrome* 通配符(* 和 ?)\n re:^chrome 正则表达式\nIP 字段还支持 CIDR(192.168.1.0/24)和 IPv6。\n多个项目的组合规则:任意正向项目匹配即可,每个“!”项目都会排除。只有排除项的字段表示“除这些之外的全部”。空字段 = 全部。", "tips.mtu": "丢弃大于指定字节数的数据包,用于复现隧道、VPN 或 NAT 后常见的“MTU 黑洞”:小包可通过,大包直接消失。0 = 关闭。", @@ -557,6 +576,7 @@ "tips.settings": "应用设置:界面语言以及表格最多显示的行数。", "tips.show_control_search": "控制“控制”页顶部是否显示“搜索”框。关闭后该搜索框会消失,Ctrl+F 将转到“连接”标签页中的搜索框。", "tips.spike": "偶发的 Ping 尖峰:按给定概率(%)为单个数据包增加额外延迟(毫秒)。被加了尖峰的数据包会晚于比它更晚发出的包到达,因此这会改变数据包顺序,而不像抖动那样让每个包的延迟都上下波动。", + "tips.spike_up": "上传时偶尔出现的更长延迟:发生的频率,以及增加的时长。仅在勾选上方选项时生效。", "tips.start": "开启或关闭流量修改。选择预设或更改字段本身不会产生影响,只有点击“开始”才会开始施加弱网效果。需要以管理员身份运行。", "tips.stat_block": "被阻断(防火墙)规则丢弃的数据包。", "tips.stat_corrupted": "负载中有一个数据位被翻转的数据包。没有负载的数据包(例如纯 ACK)无法被损坏,会直接通过且不计入这里,因此实际比例可能低于设置值。", diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py index 7906d1a..c94c824 100644 --- a/tests/test_cli_runtime.py +++ b/tests/test_cli_runtime.py @@ -262,6 +262,7 @@ def set_seed(self, *_a, **_k): pass def set_params(self, *_a, **_k): pass def set_buffer(self, *_a, **_k): pass def set_loss_burst(self, *_a, **_k): pass + def set_asymmetry(self, *_a, **_k): pass def set_dest(self, *_a, **_k): pass def set_ip_family(self, *_a, **_k): pass def set_lan(self, *_a, **_k): pass @@ -1327,3 +1328,74 @@ def test_a_mistyped_preset_is_offered_the_nearest_one(monkeypatch): # A name close to a translated one resolves through the same vocabulary. _, _, err, _ = _real_run(monkeypatch, ["--preset", "satelite"]) check("a misspelt English name is matched too", "did you mean" in err, f"({err!r})") + + +def test_the_flags_that_gained_an_up_neighbour_still_work(): + """🔴 MEASURED, and the cost the asymmetry flags were allowed to charge. + + The same shape as ``--loss-burst`` above, seven times over. ``allow_abbrev`` + is on by decision (ADR 2026-08-02), so a second option starting with + ``latency`` changes what the prefixes mean. Measured on this parser before + the names were chosen: 18 prefixes that used to work stop working + (``--lat``, ``--jit``, ``--j``, ``--cor``, ``--spike-p`` and the longer + forms of each), and every FULL flag survives, because argparse prefers an + exact match over a prefix one. + + That second half is the one that matters and the reason the cost was + acceptable: ``settings_to_cli`` emits full flags, so no stored ``Reproduce:`` + command and no documented example moves. Only a hand-typed abbreviation does. + + The alternative measured against this was ``--up-latency``, which costs one + prefix instead of eighteen and was NOT taken: every other modifier in this + parser reads ``-`` (``--loss-burst``, ``--spike-prob``, + ``--rst-cooldown``, ``--flap-down``, ``--nat-timeout``), and ``--up-`` would + also read as belonging to ``--up``, the upload SPEED limit. + """ + parser = cli_module.build_arg_parser() + for flag, dest, neighbour in (("--latency", "latency", "latency_up"), + ("--jitter", "jitter", "jitter_up"), + ("--loss", "loss", "loss_up"), + ("--corrupt", "corrupt", "corrupt_up"), + ("--dup", "dup", "dup_up"), + ("--spike-ms", "spike_ms", "spike_ms_up"), + ("--spike-prob", "spike_prob", "spike_prob_up")): + args = parser.parse_args([flag, "7"]) + check(f"{flag} still means what it meant", + getattr(args, dest) == 7.0, f"({getattr(args, dest)})") + check(f"{flag} did not swallow its -up neighbour", + getattr(args, neighbour) is None, f"({getattr(args, neighbour)})") + args = parser.parse_args([f"{flag}-up", "3"]) + check(f"{flag}-up reaches the upload value", + getattr(args, neighbour) == 3.0, f"({getattr(args, neighbour)})") + + for prefix in ("--lat", "--jit", "--j", "--cor", "--spike-p"): + try: + parser.parse_args([prefix, "5"]) + code = 0 + except SystemExit as exc: + code = exc.code + check(f"{prefix} is now ambiguous, which is the accepted cost of the names", + code == 2, f"(exit {code})") + + +def test_an_upload_impairment_earns_the_blast_radius_warning(monkeypatch): + """🔴 The rule this project writes in red: never damage traffic globally in + silence. ``--asym --loss-up 50`` cuts half of everything this machine SENDS, + with no target and no deadline, and the download loss the warning used to + look at is zero - so without ``impairs`` on the upload fields it would have + started without a word. + """ + _, _, err, _ = _real_run(monkeypatch, ["--asym", "--loss-up", "50"]) + check("warning: an upload-only impairment is still machine-wide", _warned(err)) + + +def test_an_upload_value_left_behind_by_the_switch_warns_about_nothing(monkeypatch): + """The other half, and the reason ``Field.live_when`` exists. + + Turning the switch back off leaves the seven values sitting in the form, and + they impair nothing at all. Warning about them would cry wolf on the ordinary + path of using the feature and then changing your mind - and a warning that + fires when nothing is wrong is how a real one stops being read. + """ + _, _, err, _ = _real_run(monkeypatch, ["--loss-up", "50"]) + check("no warning when the switch that reads it is off", not _warned(err)) diff --git a/tests/test_field_registry.py b/tests/test_field_registry.py index 9dba876..a046b93 100644 --- a/tests/test_field_registry.py +++ b/tests/test_field_registry.py @@ -175,7 +175,14 @@ def test_profile_scope_is_derived(): set(F.PROFILE_FIELDS) == {"loss", "loss_burst", "corrupt", "dup", "latency", "jitter", "down", "up", "buffer", "spike_prob", - "spike_ms", "flap_period", "flap_down"}, + "spike_ms", "flap_period", "flap_down", + # asymmetry: the switch and the seven values + # it governs. A profile that stored "200 ms" + # without saying it was download-only would + # store a link nobody has. + "asym", "latency_up", "jitter_up", + "spike_prob_up", "spike_ms_up", + "loss_up", "corrupt_up", "dup_up"}, f"({F.PROFILE_FIELDS})") non_profile = {k for k, _ in F.NON_PROFILE_FIELDS} check("registry: profile and non-profile fields partition the model", diff --git a/tests/test_passthrough.py b/tests/test_passthrough.py index e8387c9..269d8f3 100644 --- a/tests/test_passthrough.py +++ b/tests/test_passthrough.py @@ -66,6 +66,11 @@ IMPAIRMENT_OFF = dict( {key: off_value(FIELDS[key]) for key in IMPAIRING_KEYS + NARROWING_KEYS}, spike_ms=0, flap_down=0, loss_burst=0, block_reject=False, + # The upload spike's size, for the same reason as spike_ms beside it: it sits + # behind its own probability's gate, so it arms nothing, and 0 really is its + # cold value. The upload IMPAIRMENTS need no line here - they declare + # ``impairs`` and arrive through the derived half above. + spike_ms_up=0, ) # The profile fields that can impair traffic, and their "no impairment" value. From e9e83bd5b43699cb6d43b1921ab1fb557b09b054 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:25:36 +0200 Subject: [PATCH 3/6] test(profiles): prove an old profile still means what it meant A profile file names only the keys it carries and the store fills the rest from each field's default, so a profile written before asymmetry loads whatever the design is. The question was never whether it loads, it is what it then means, and the existing legacy guard could not see this class: it flags a field zero-filled against a non-zero default, and every upload value defaults to zero. The new guard checks that the stored numbers ARRIVE and that both directions then carry them. Mutation run: reading the upload values regardless of the switch, which is the rejected design where absent simply means zero, turns a stored 7% loss into 7% down and 0% up and fails it. A second guard covers `asym` being the first bool in the profile scope, where everything is stored through float(). Mutation run: taking the switch out of the profile scope fails it. The fixture is built from the keys that existed before the change rather than read from the stored corpus, so it keeps describing the pre-asymmetry shape however that corpus is regenerated later. Co-Authored-By: Claude Opus 5 --- tests/test_legacy_files.py | 104 +++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/test_legacy_files.py b/tests/test_legacy_files.py index 32de631..6195973 100644 --- a/tests/test_legacy_files.py +++ b/tests/test_legacy_files.py @@ -177,3 +177,107 @@ def _split_command(command): for part in parts[2:] if len(parts) > 1 and parts[0] == "python" else parts[1:]: argv.append(part.strip('"')) return argv + + +# The seven values asymmetry splits, as (settings key, upload key). +_ASYMMETRIC_PAIRS = (("loss", "loss_up"), ("corrupt", "corrupt_up"), + ("dup", "dup_up"), ("latency", "latency_up"), + ("jitter", "jitter_up"), ("spike_prob", "spike_prob_up"), + ("spike_ms", "spike_ms_up")) + + +def test_a_profile_written_before_asymmetry_still_means_what_it_meant(): + """🔴 The migration this feature could have broken in SILENCE. + + A profile file names only the keys it carries, and `ProfileStore._clean` + fills every key it does not mention with that field's default. So a profile + written before asymmetry existed loads without any error whatever the design + is - the question was never whether it LOADS, it is what it then MEANS. + + Had the upload values simply defaulted to zero, "latency 200" would have + quietly become "200 ms down, 0 ms up", and + `test_a_profile_from_every_release_still_loads_with_its_own_defaults` above + could not have seen it: that guard flags a field zero-filled against a + NON-ZERO default, and every upload value's default is zero. + + The switch is what makes the old meaning survive by construction - absent + `asym` reads as off, and then the download values apply both ways. This test + is the proof, and it is written not to depend on the stored corpus: it builds + the profile from the keys that existed BEFORE the change, so it keeps testing + the pre-asymmetry shape however the corpus is regenerated later. + """ + import json as _json + + from beantester.core import BeanCore + from beantester.presets import SETTING_TO_PRESET, preset_to_settings + from beantester.settings import apply_settings + from beantester.gui.profiles import ProfileStore + + old_shape = {SETTING_TO_PRESET[key]: value for key, value in ( + ("loss", 7), ("corrupt", 3), ("dup", 2), ("latency", 200), + ("jitter", 40), ("spike_prob", 5), ("spike_ms", 90), + ("down", 512), ("up", 128), ("buffer", 1000), + ("loss_burst", 0), ("flap_period", 0), ("flap_down", 0))} + check("the fixture really is pre-asymmetry (no upload key in it)", + not [k for k in old_shape if k.endswith("_up")], f"({sorted(old_shape)})") + + import tempfile + path = os.path.join(tempfile.mkdtemp(), "profiles.json") + with open(path, "w", encoding="utf-8") as handle: + _json.dump({"written before asymmetry": old_shape}, handle) + + values = ProfileStore(path).get("written before asymmetry") + check("it loads", values is not None, "(the store dropped it)") + settings = preset_to_settings(values) + + core = BeanCore() + apply_settings(core, settings) + down, up = core._dir[False], core._dir[True] + + # First that the file's numbers ARRIVED. Without this the checks below would + # pass just as well on a profile that loaded as nothing but zeros, which is + # the exact failure this file exists to catch. + check("the stored latency reached the core", + abs(down.latency_s - 0.2) < 1e-9, f"({down.latency_s})") + check("the stored loss reached the core", + abs(down.loss - 0.07) < 1e-9, f"({down.loss})") + for key, up_key in _ASYMMETRIC_PAIRS: + attr = {"loss": "loss", "corrupt": "corrupt", "dup": "dup", + "latency": "latency_s", "jitter": "jitter_s", + "spike_prob": "spike_prob", "spike_ms": "spike_s"}[key] + check(f"{key}: the upload direction still gets the stored value, " + f"not a zero from {up_key}", + getattr(up, attr) == getattr(down, attr), + f"(down={getattr(down, attr)}, up={getattr(up, attr)})") + + +def test_the_switch_survives_a_profile_round_trip(): + """`asym` is the first BOOL in the profile scope, and `_clean` floats + everything it stores. A switch that came back as 0.0 and was then read as + False would turn every saved asymmetric profile symmetric on reload - the + values would all be there, and the link would be the wrong one.""" + import json as _json + import tempfile + + from beantester.gui.profiles import ProfileStore + from beantester.presets import settings_to_preset, preset_to_settings + from beantester.settings import DEFAULT_SETTINGS + + saved = dict(DEFAULT_SETTINGS, asym=True, latency=200, latency_up=30) + path = os.path.join(tempfile.mkdtemp(), "profiles.json") + store = ProfileStore(path) + store.set("asymmetric link", settings_to_preset(saved)) + check("it saved", store.persist() is None, f"({store.problem})") + + with open(path, encoding="utf-8") as handle: + on_disk = _json.load(handle)["asymmetric link"] + check("the switch is on disk as a number, not dropped", + "asym" in on_disk, f"({sorted(on_disk)})") + + reloaded = preset_to_settings(ProfileStore(path).get("asymmetric link")) + check("the switch comes back on", bool(reloaded["asym"]), + f"({reloaded['asym']!r})") + check("with its own upload value", float(reloaded["latency_up"]) == 30.0, + f"({reloaded['latency_up']!r})") + check("and the download value beside it", float(reloaded["latency"]) == 200.0, + f"({reloaded['latency']!r})") From 584192f06c2201e1f4138c7c473ac743f804b2d1 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:36:12 +0200 Subject: [PATCH 4/6] feat(gui): the asymmetry switch fills its fields and greys them when off Ticking the switch copies each upload field from the download field it mirrors, which is the whole reason the card needs no new rule explained: the second set of boxes is never blank, so nobody has to work out what an empty one would have meant. It describes the same link it described a moment ago, and only an edit changes anything. Unticking leaves the values alone, since they are inert either way and wiping them would punish somebody comparing against a symmetric run. While the switch is off those fields are disabled and their labels greyed, through the same `apply_overrides` that already did this for a field another field had taken over. An editable box that changes nothing is a lie about what the tool is doing, whichever of the two reasons makes it dead. The pairs come from the registry (`Field.mirror_of`), so a value added to the card later cannot be left out of the copy, and a new guard checks that every gated field names a switch and a mirror that both exist and agree on kind, bounds and unit. This also fixes a defect it exposed, which is a class rather than one field: both loops that fill the form from a preset or profile called number_string on every value, which held only while every profile field was a number. The switch went in as the string "0", which real tkinter coerces back to False by luck, and a profile storing it on would have arrived as "1" and worked for the same wrong reason. The conversion now dispatches on the field kind. Mutation run: restoring the uniform call fails the new guard. Both size ratchets fired and both took the routine door. app.py went five lines over its ceiling, and lowering that ceiling was not available - the crowd band is 70% of it and engine.py sits one line under the band - so the helper moved to fields.py beside off_value and the existing import absorbed it. `_on_switch` crossed the nesting crowd count, so the copy moved out. Co-Authored-By: Claude Opus 5 --- beantester/fields.py | 52 +++++++++++++---- beantester/gui/app.py | 6 +- beantester/gui/form.py | 50 ++++++++++++++++- tests/test_field_registry.py | 40 +++++++++++++ tests/test_gui_layout.py | 105 +++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 16 deletions(-) diff --git a/beantester/fields.py b/beantester/fields.py index cc453e7..9f9cc60 100644 --- a/beantester/fields.py +++ b/beantester/fields.py @@ -12,14 +12,15 @@ ``SECTIONS`` describes how the fields are grouped on the Control page; the page is a renderer of this table, not a hand-written form. -Layering: this module depends only on ``matchers`` (expression kinds) and -``processes`` (the target field's i18n key) - no tkinter, no i18n lookups at -import time. +Layering: this module depends only on ``matchers`` (expression kinds), +``processes`` (the target field's i18n key) and ``utils`` (number formatting for +``widget_value``) - no tkinter, no i18n lookups at import time. """ from typing import NamedTuple, Optional, Tuple from .matchers import KIND_INT, KIND_IP, KIND_PROCESS, PORT_BOUNDS from .processes import TARGET_FIELD +from .utils import number_string # -- field kinds ----------------------------------------------------------- # NUMBER = "number" # float, optional inclusive bounds @@ -98,6 +99,11 @@ class Field(NamedTuple): # another field's value. See ``settings.armed_global_impairments``, which # already carries one case the registry cannot state on its own. live_when: str = "" + # Key of the field this one is the other direction's counterpart of. The form + # copies that field's value across when ``live_when`` is switched on, which is + # what makes the second set of boxes never blank: switching it on describes + # the same link it described a moment ago, and only an edit changes anything. + mirror_of: str = "" FIELD_DEFS = ( @@ -243,27 +249,34 @@ class Field(NamedTuple): # ``settings.armed_global_impairments`` reads both. Field("latency_up", NUMBER, "fields.latency_up", "asymmetry", unit="ms", bounds=MS, tip="tips.latency_up", in_profile=True, preset_key="lat_up", - cli="latency-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="latency-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="latency"), Field("jitter_up", NUMBER, "fields.jitter_up", "asymmetry", unit="ms", bounds=MS, tip="tips.jitter_up", in_profile=True, preset_key="jit_up", - cli="jitter-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="jitter-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="jitter"), Field("spike_prob_up", NUMBER, "fields.spike_prob_up", "asymmetry", unit="%", bounds=PCT, width=6, tip="tips.spike_up", in_profile=True, - cli="spike-prob-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="spike-prob-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="spike_prob"), # Parameter of the spike ABOVE it, exactly as spike_ms is of spike_prob: it # sits behind that gate in decide() step 10 and arms nothing by itself. Field("spike_ms_up", NUMBER, "fields.spike_ms_up", "asymmetry", unit="ms", bounds=MS, width=8, tip="tips.spike_up", in_profile=True, - cli="spike-ms-up", parameter_of="spike_prob_up", live_when="asym"), + cli="spike-ms-up", parameter_of="spike_prob_up", live_when="asym", + mirror_of="spike_ms"), Field("loss_up", NUMBER, "fields.loss_up", "asymmetry", unit="%", bounds=PCT, width=6, tip="tips.loss_up", in_profile=True, - cli="loss-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="loss-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="loss"), Field("corrupt_up", NUMBER, "fields.corruption_up", "asymmetry", unit="%", bounds=PCT, width=6, tip="tips.corrupt_up", in_profile=True, - cli="corrupt-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="corrupt-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="corrupt"), Field("dup_up", NUMBER, "fields.duplication_up", "asymmetry", unit="%", bounds=PCT, width=6, tip="tips.dup_up", in_profile=True, - cli="dup-up", impairs=IMPAIRS_ALL, live_when="asym"), + cli="dup-up", impairs=IMPAIRS_ALL, live_when="asym", + mirror_of="dup"), # -- flapping ---------------------------------------------------------- # # in_profile: the outage is PERIODIC and phase-locked to the session start @@ -541,6 +554,25 @@ def overriding_field(field): return FIELDS.get(field.overridden_by) if field.overridden_by else None +def widget_value(key, value): + """A stored profile value, in the form its widget variable expects. + + Both loops that fill the form from a preset or a profile used to call + ``number_string`` on every value, which held only for as long as every + profile field WAS a number. The asymmetry switch is a checkbox, and + ``number_string(False)`` is the string ``"0"`` - a value real tkinter + coerces back to False by luck, and one that a profile storing the switch ON + would have delivered as ``"1"`` and that would have worked for the same + wrong reason. The next checkbox to join the profile scope would have + inherited the bug in silence. + + Here rather than in the GUI because it is a property of the FIELD, and it + lives beside ``off_value`` for the same reason: both answer "what does this + field's value look like" from the registry, without a widget in sight. + """ + return bool(value) if FIELDS[key].kind == BOOL else number_string(value) + + def off_value(field): """Value a field takes when its section's 'enable' toggle is unchecked.""" if field.kind in (EXPR, SCHEDULE): diff --git a/beantester/gui/app.py b/beantester/gui/app.py index ae84951..1febb7e 100644 --- a/beantester/gui/app.py +++ b/beantester/gui/app.py @@ -30,7 +30,7 @@ from ..fields import CHOICE as F_CHOICE from ..fields import NUMBER as F_NUMBER from ..fields import SEED as F_SEED -from ..fields import FIELD_DEFS, SECTIONS, UI_ONLY_KEYS, off_value +from ..fields import FIELD_DEFS, SECTIONS, UI_ONLY_KEYS, off_value, widget_value from ..filters import cli_key_for, i18n_key_for, i18n_keys, windivert_for from .. import crashlog, winenv from . import csv_export @@ -319,7 +319,7 @@ def _init_vars(self): # so a freshly opened tool already claimed to be degrading the link while # the profile box said nothing of the sort. for key, value in preset_to_settings(DEFAULT_PROFILE).items(): - self.vars[key].set(number_string(value)) + self.vars[key].set(widget_value(key, value)) # -- UI construction ------------------------------------------------------- # def _build_ui(self): @@ -968,7 +968,7 @@ def select_profile(self, key): return self._set_profile_key(key) for setting, value in preset_to_settings(preset).items(): - self.vars[setting].set(number_string(value)) + self.vars[setting].set(widget_value(setting, value)) self._sync_profile_widgets() self.form.validate_all() self.form.apply_overrides() diff --git a/beantester/gui/form.py b/beantester/gui/form.py index 08ee6d5..124599d 100644 --- a/beantester/gui/form.py +++ b/beantester/gui/form.py @@ -233,7 +233,7 @@ def _place_one(self, row, field, sec): if field.kind == F.BOOL: widget = ttk.Checkbutton(row, text=T(field.label), variable=app.vars[field.key], - command=app.on_form_changed) + command=lambda f=field: self._on_switch(f)) widget.pack(side="left", anchor="w", padx=(0, _gap_after(field))) add_tooltip(widget, field.tip) self.entries[field.key] = widget @@ -345,6 +345,36 @@ def _show_match_help(self): dialogs.show_help(self.app.root, T("dialogs.match_help_title"), T("dialogs.match_help")) + def _on_switch(self, field): + """A checkbox was clicked. Some of them govern other fields. + + Turning a switch ON copies each dependent field's mirror across, which is + the whole reason the asymmetry card can exist without teaching anybody a + new rule: the second set of boxes is never blank, so no reader has to + work out what an empty one would have meant. It describes the same link + it described a moment ago, and only an edit changes anything. + + Copying happens on the way ON only. On the way off the values stay where + they are - they are inert either way, and wiping them would throw away + work for somebody who unticked the box to compare against a symmetric + run and then ticked it back. + """ + var = self.app.vars.get(field.key) + if var is not None and F.is_active(field, var.get()): + self._copy_mirrors(field.key) + self.apply_overrides() + self.app.on_form_changed() + + def _copy_mirrors(self, switch_key): + """Fill every field this switch governs from the one it mirrors.""" + for other in F.FIELD_DEFS: + if other.live_when != switch_key or not other.mirror_of: + continue + source = self.app.vars.get(other.mirror_of) + target = self.app.vars.get(other.key) + if source is not None and target is not None: + target.set(source.get()) + def _add_help_button(self, parent, field): """The "?" for any registry field that declares its own help sheet. @@ -489,6 +519,20 @@ def is_locked(self, key): """ return bool(FIELDS[key].start_only and getattr(self.app, "running", False)) + def is_dormant(self, key): + """True when the switch this field needs is currently off. + + The mirror image of ``is_overridden``: that one asks whether another + field has TAKEN OVER, this one whether the field is being read at all. + Both end in the same place - a box that changes nothing must not look + editable - which is why ``apply_overrides`` treats them together. + """ + field = FIELDS[key] + if not field.live_when: + return False + var = self.app.vars.get(field.live_when) + return not (var is not None and F.is_active(FIELDS[field.live_when], var.get())) + def is_overridden(self, key): """True when another field currently takes precedence over ``key``.""" field = FIELDS[key] @@ -512,11 +556,11 @@ def apply_overrides(self): note_keys = [] for key in sec.fields: field = FIELDS[key] - if not (field.overridden_by or field.start_only): + if not (field.overridden_by or field.start_only or field.live_when): continue overridden = self.is_overridden(key) locked = self.is_locked(key) - dead = overridden or locked + dead = overridden or locked or self.is_dormant(key) if overridden and field.override_note: note_keys.append(field.override_note) elif locked: diff --git a/tests/test_field_registry.py b/tests/test_field_registry.py index a046b93..c5b61d3 100644 --- a/tests/test_field_registry.py +++ b/tests/test_field_registry.py @@ -264,3 +264,43 @@ def test_every_registry_field_reaches_the_settings_through_its_cli_flag(): else: check(f"{field.key}: {flag} {raw} reaches settings[{field.key!r}]", str(got) == str(raw), f"(got {got!r})") + + +def test_a_field_that_waits_on_a_switch_names_a_real_one_and_what_it_mirrors(): + """The accounting for ``live_when`` / ``mirror_of``, which three places read. + + ``settings.armed_global_impairments`` asks whether the switch is on before it + calls an upload value armed, the form greys the field while it is off, and + the form copies the mirror across when it goes on. A typo in either key would + not raise anywhere: the warning would silently stop covering a field, and the + copy would silently skip one - leaving exactly the blank box the switch + exists to avoid. + """ + for field in F.FIELD_DEFS: + if not field.live_when: + continue + switch = F.FIELDS.get(field.live_when) + check(f"{field.key}: live_when names a field that exists", + switch is not None, f"({field.live_when!r})") + if switch is not None: + check(f"{field.key}: and that field is a switch", + switch.kind == F.BOOL, f"({switch.key} is {switch.kind})") + check(f"{field.key}: which is not itself waiting on one", + not switch.live_when, f"({switch.key} waits on {switch.live_when!r})") + check(f"{field.key}: names the field it mirrors", + bool(field.mirror_of), "(nothing to copy across when the switch goes on)") + mirror = F.FIELDS.get(field.mirror_of) + check(f"{field.key}: and that field exists", mirror is not None, + f"({field.mirror_of!r})") + if mirror is not None: + check(f"{field.key}: with the same kind, bounds and unit as its mirror", + (mirror.kind, mirror.bounds, mirror.unit) + == (field.kind, field.bounds, field.unit), + f"({mirror.key}: {mirror.kind}/{mirror.bounds}/{mirror.unit!r} vs " + f"{field.kind}/{field.bounds}/{field.unit!r})") + check(f"{field.key}: and its mirror is not itself gated", + not mirror.live_when, f"({mirror.key} waits on {mirror.live_when!r})") + # The canary: an empty scan satisfies every check above. + check("some field waits on a switch at all", + len(F.CONDITIONAL_IMPAIRING_KEYS) >= 1, + f"({F.CONDITIONAL_IMPAIRING_KEYS})") diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index d3ce36a..db65af1 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -1007,3 +1007,108 @@ def test_the_two_lan_switches_share_one_row(): gap = gap[1] if isinstance(gap, (tuple, list)) else gap assert gap, "no room to the right of the first switch: %r" % (lan.pack_info,) """) + + +def test_turning_asymmetry_on_copies_the_download_values_across(): + """🔴 The design decision this whole card rests on, in the one place a user + meets it. + + The alternative was "leave the upload box empty to mean the same as + download". An empty numeric box means ZERO everywhere else in this program, + so the reading a newcomer would give it is the one that would be wrong - and + in a reproduction command the inheritance would not be visible at all. + + The switch removes the question instead of answering it: ticking it copies + each field's mirror across, so the second set of boxes is never blank and + describes the same link it described a moment ago. Only an edit changes + anything. The pairs come from ``Field.mirror_of`` rather than from a list + here, so a value added to the card later cannot be left out of the copy. + """ + run_gui(""" + from beantester.fields import FIELD_DEFS + mirrors = [(f.key, f.mirror_of) for f in FIELD_DEFS + if f.live_when == "asym" and f.mirror_of] + assert len(mirrors) == 7, mirrors + + for _up, base in mirrors: # distinct values, so a copy shows + app.vars[base].set("42") + for up, _base in mirrors: + app.vars[up].set("") + assert not app.vars["asym"].get() + + app.vars["asym"].set(True) # what the checkbox variable does + app.form._on_switch(__import__("beantester.fields", fromlist=["FIELDS"]) + .FIELDS["asym"]) # ...and then its command + for up, base in mirrors: + assert app.vars[up].get() == app.vars[base].get() == "42", (up, base, + app.vars[up].get()) + + # Turning it back off leaves the work alone: the values are inert either + # way, and wiping them would punish somebody comparing against a + # symmetric run. + app.vars["latency_up"].set("7") + app.vars["asym"].set(False) + app.form._on_switch(__import__("beantester.fields", fromlist=["FIELDS"]) + .FIELDS["asym"]) + assert app.vars["latency_up"].get() == "7", app.vars["latency_up"].get() + """) + + +def test_the_upload_fields_look_dead_while_the_switch_is_off(): + """An editable box that changes nothing is a lie about what the tool is + doing - the rule ``apply_overrides`` already enforced for a field another + field had taken over. A field waiting on a switch is the same statement seen + from the other side, so it goes through the same place.""" + run_gui(""" + from beantester.fields import FIELD_DEFS + upload = [f.key for f in FIELD_DEFS if f.live_when == "asym"] + assert upload + + app.vars["asym"].set(False) + app.form.apply_overrides() + for key in upload: + assert app.form.is_dormant(key), key + assert app.form.entries[key].cget("state") == "disabled", key + + app.vars["asym"].set(True) + app.form.apply_overrides() + for key in upload: + assert not app.form.is_dormant(key), key + assert app.form.entries[key].cget("state") == "normal", key + # the switch itself is never dormant - nothing gates it + assert not app.form.is_dormant("asym") + """) + + +def test_a_profile_switch_reaches_the_form_as_a_switch_not_as_text(): + """🔴 FOUND by the asymmetry work, and it is a class rather than one field. + + Both loops that fill the form from a preset or profile called + ``number_string`` on every value. That held for as long as every profile + field was a number - and ``asym`` is the first that is not. The switch went + in as the string ``"0"``, which real tkinter coerces back to False by luck, + and a profile storing it ON would have arrived as ``"1"`` and worked for the + same wrong reason. The next checkbox to join the profile scope would have + inherited it in silence. + """ + run_gui(""" + from beantester.fields import FIELD_DEFS + from beantester.presets import settings_to_preset + from beantester.settings import DEFAULT_SETTINGS + + switches = [f.key for f in FIELD_DEFS if f.in_profile and f.kind == "bool"] + assert switches, "no switch is stored in a profile any more" + + for key in switches: # a fresh form: off, as a BOOL + assert app.vars[key].get() is False, (key, repr(app.vars[key].get())) + + app.profiles.set("asymmetric link", + settings_to_preset(dict(DEFAULT_SETTINGS, asym=True, + latency=200, latency_up=30))) + app.select_profile("asymmetric link") + assert app.vars["asym"].get() is True, repr(app.vars["asym"].get()) + assert app.vars["latency_up"].get() == "30", app.vars["latency_up"].get() + + app.select_profile("presets.perfect") # ...and back off, still a BOOL + assert app.vars["asym"].get() is False, repr(app.vars["asym"].get()) + """) From da051706cbf1e42501a6a7c2e8c5f037a9802d57 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:46:02 +0200 Subject: [PATCH 5/6] feat(settings): say when a one-way filter would ignore half the values The outbound-only and inbound-only traffic filters are applied in the driver, so the other direction never reaches this process. With asymmetry on, half the values the user just typed then describe traffic the session cannot see, the summary still lists them, and the counters read as the tool ignoring its own form. It is said once per apply rather than refused, which is the answer the two LAN switches and the address-family pair already give: impairing one direction on purpose is a legitimate thing to ask for. This also moves seven mutation proofs out of prose and into the registry. Leaving them as sentences would be exactly the unguarded claim that file exists to prevent, and the registry test caught two of its own entries going stale on this branch: the repro entry aimed at an `if` that became a table row when the complexity ratchet fired, and the burst-loss entry named `_recompute_burst`, which became `_recompute` when the value sets went per direction. Both are re-aimed at the same statements. Run on this branch with `--changed origin/master`: 73 mutations, 73 caught, none survived. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 ++++--- beantester/settings.py | 8 +++ lang/en.json | 1 + lang/pl.json | 1 + lang/zh.json | 1 + tests/test_mutation_registry.py | 87 +++++++++++++++++++++++--- tests/test_settings_config_scenario.py | 38 +++++++++++ 7 files changed, 145 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9796b..8b97845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol `--jitter-up`, `--spike-prob-up` and `--spike-ms-up`. Profiles remember all of it, and the session description says what the upload half is doing. -### Changed - -- **Some command-line shortcuts stopped working, and the full flags did not.** Adding the - upload flags means `--latency` is no longer the only option starting with "latency", so - short forms like `--lat`, `--jit`, `--j`, `--cor` and `--spike-p` are now ambiguous and are - refused. Every full flag still works, so saved reproduction commands and every example in - this documentation are unaffected - only hand-typed abbreviations need writing out in full. - - **A blocked connection can be refused instead of ignored.** A new "Refuse blocked connections" checkbox in the Block card, and `--block-reject`. Without it a blocked connection gets no answer and the program you are testing waits until it gives up on its @@ -38,6 +30,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol An existing stats CSV is rotated to a dated backup the first time the new column is written. +### Changed + +- **A warning when a one-way traffic filter would ignore half your asymmetry settings.** + The "Outgoing only" and "Incoming only" traffic filters work inside the driver, so the other + direction never reaches the tool at all. With asymmetry on, that means half the values you + typed describe traffic this session cannot see - and until now nothing said so: the session + description still listed them and the counters looked like the tool ignoring its own form. + The run now says it once, and still runs, because impairing one direction on purpose is a + perfectly good thing to ask for. + +- **Some command-line shortcuts stopped working, and the full flags did not.** Adding the + upload flags means `--latency` is no longer the only option starting with "latency", so + short forms like `--lat`, `--jit`, `--j`, `--cor` and `--spike-p` are now ambiguous and are + refused. Every full flag still works, so saved reproduction commands and every example in + this documentation are unaffected - only hand-typed abbreviations need writing out in full. + ## [0.6.0] - 2026-09-04 ### Added diff --git a/beantester/settings.py b/beantester/settings.py index 74855f0..c118f5c 100644 --- a/beantester/settings.py +++ b/beantester/settings.py @@ -626,6 +626,14 @@ def apply_settings(engine, s, log=lambda *_: None): # instead of being refused: refusing would break a run somebody meant. if g("lan_mode") and g("internet_only"): log(T("log.lan_and_internet_only")) + # Said out loud for the same reason as the two above, and it is the one trap + # asymmetry brings that no counter would reveal: a one-way traffic filter is + # applied IN THE DRIVER, so the other direction is never handed over at all. + # Half the values the user just typed would then describe traffic this + # session cannot see, the summary would still list them, and the numbers on + # screen would look like the tool ignoring its own form. + if g("asym") and g("filter") in ("out", "in"): + log(T("log.asym_one_way_filter")) block_ip = setting_expression("block_ip", g("block_ip")) block_port = setting_expression("block_port", g("block_port")) try: diff --git a/lang/en.json b/lang/en.json index dae3a20..973bcb1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -273,6 +273,7 @@ "log.applied_changes": "Applied changes", "log.apply_needed": "Click \"Apply changes\" to push this to the running session.", "log.apply_needs_start": "Apply changes works after start (START).", + "log.asym_one_way_filter": "The traffic filter captures one direction only, so the values for the other direction will not be applied.", "log.block_ip_added": "Blocking", "log.bug_marked": "BUG MARKED", "log.config_loaded_from": "Config loaded from", diff --git a/lang/pl.json b/lang/pl.json index 6a9e538..28482d0 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -273,6 +273,7 @@ "log.applied_changes": "Zastosowano zmiany", "log.apply_needed": "Kliknij „Zastosuj zmiany”, aby przekazać to do działającej sesji.", "log.apply_needs_start": "Zastosuj zmiany działa po uruchomieniu (START).", + "log.asym_one_way_filter": "Filtr ruchu przechwytuje tylko jeden kierunek, więc wartości dla drugiego kierunku nie zostaną zastosowane.", "log.block_ip_added": "Blokowanie", "log.bug_marked": "ZAZNACZONO BŁĄD", "log.config_loaded_from": "Wczytano konfigurację z", diff --git a/lang/zh.json b/lang/zh.json index a197fab..b6a76ce 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -273,6 +273,7 @@ "log.applied_changes": "已应用更改", "log.apply_needed": "点击“应用更改”即可把这些设置推送到当前会话。", "log.apply_needs_start": "启动会话后才能应用更改。", + "log.asym_one_way_filter": "流量过滤器只捕获一个方向,因此另一个方向的数值不会生效。", "log.block_ip_added": "正在阻断", "log.bug_marked": "已标记故障", "log.config_loaded_from": "配置已加载自", diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 0edb230..e9dcb0e 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -1908,8 +1908,11 @@ # --narrow-filter went missing for weeks. "label": "repro: a flag drops out of the reproduction command", "file": "beantester/repro.py", - "old": ' if g("internet_only"):\n args += ["--internet-only"]', - "new": " pass", + # The seven switches became a table when the complexity ratchet fired on + # the seventh, so the mutation drops one ROW instead of one branch. Same + # statement, and the same test still has to redden. + "old": '("internet_only", "--internet-only"),\n', + "new": "", "test": "test_every_setting_with_a_flag_reaches_the_reproduction_command", }, { @@ -1958,11 +1961,14 @@ # alone would let the ORDER of two setter calls decide correctness. "label": "burst loss: changing only the loss leaves the chain stale", "file": "beantester/core.py", - "old": " self.rate_up = self._rate_bps(up_kbps)\n" - " # The burst chain is derived from the loss AND from the run length,\n" - " # so it has to be re-derived here too - see _recompute_burst.\n" - " self._recompute_burst()", - "new": " self.rate_up = self._rate_bps(up_kbps)", + # `_recompute_burst` became `_recompute` when the value sets went per + # direction (it now re-derives both of them, not only the chain), so the + # pattern follows the rename. Same statement: drop the re-derivation from + # the setter that can change the loss. + "old": " # for the burst chain, from the run length too), so it has to be\n" + " # re-derived here - see _recompute.\n" + " self._recompute()", + "new": " pass", "test": "test_changing_only_the_loss_re_derives_the_chain", }, { @@ -2210,6 +2216,73 @@ "new": " {{REPO_URL}}/raw/master/bean.png", "test": "test_the_chocolatey_icon_is_a_pinned_cdn_url", }, + { + # Two directions losing different amounts need two chains, because p is + # derived from the loss. Sharing one pair meant re-deriving it reset BOTH + # runs, so a change to the upload ended the download's run in flight. + "label": "core: the burst chains share one reset again", + "file": "beantester/core.py", + "old": " self._loss_bad[outbound] = False", + "new": " self._loss_bad[True] = self._loss_bad[False] = False", + "test": "test_raising_the_upload_loss_does_not_cut_a_download_run_in_flight", + }, + { + # ...and the other half of the same fix: both chains derived from the + # DOWNLOAD loss, so the upload walks a chain built for the wrong number. + "label": "core: both burst chains come from the download loss", + "file": "beantester/core.py", + "old": " params = burst_loss_params(loss, self.loss_burst)", + "new": " params = burst_loss_params(self.loss, self.loss_burst)", + "test": "test_raising_the_upload_loss_does_not_cut_a_download_run_in_flight", + }, + { + # The rejected design, where an absent upload value simply means zero. A + # profile written before asymmetry then silently becomes "7% down, 0% up" + # - it still LOADS, which is why loading was never the question. + "label": "core: upload values are read whether or not the switch is on", + "file": "beantester/core.py", + "old": " if self.asymmetric:", + "new": " if True:", + "test": "test_a_profile_written_before_asymmetry_still_means_what_it_meant", + }, + { + # Without impairs, `--asym --loss-up 50` cuts half of everything this + # machine sends, with no target and no deadline, and starts in silence. + "label": "fields: an upload impairment stops declaring its blast radius", + "file": "beantester/fields.py", + "old": ' cli="loss-up", impairs=IMPAIRS_ALL, live_when="asym",', + "new": ' cli="loss-up", live_when="asym",', + "test": "test_an_upload_impairment_earns_the_blast_radius_warning", + }, + { + # ...and without live_when it cries wolf on the ordinary path of trying + # the feature and unticking the box again. + "label": "fields: an upload impairment forgets which switch reads it", + "file": "beantester/fields.py", + "old": ' cli="loss-up", impairs=IMPAIRS_ALL, live_when="asym",', + "new": ' cli="loss-up", impairs=IMPAIRS_ALL,', + "test": "test_an_upload_value_left_behind_by_the_switch_warns_about_nothing", + }, + { + # The switch is the first BOOL in the profile scope. Dropped from it, + # every saved asymmetric profile comes back symmetric with all seven + # values present - the wrong link, described in full. + "label": "fields: the asymmetry switch leaves the profile scope", + "file": "beantester/fields.py", + "old": ' tip="tips.asym", span=True, cli="asym", in_profile=True,', + "new": ' tip="tips.asym", span=True, cli="asym", in_profile=False,', + "test": "test_the_switch_survives_a_profile_round_trip", + }, + { + # The uniform number_string both form loaders used before a profile field + # could be a checkbox: the switch arrives as the string "0", which real + # tkinter coerces back to False by luck. + "label": "fields: a profile switch reaches the form as text", + "file": "beantester/fields.py", + "old": " return bool(value) if FIELDS[key].kind == BOOL else number_string(value)", + "new": " return number_string(value)", + "test": "test_a_profile_switch_reaches_the_form_as_a_switch_not_as_text", + }, ] # The runner's own check: a patch that cannot compile must be reported as BROKEN, not diff --git a/tests/test_settings_config_scenario.py b/tests/test_settings_config_scenario.py index 7a4052f..42f7486 100644 --- a/tests/test_settings_config_scenario.py +++ b/tests/test_settings_config_scenario.py @@ -676,3 +676,41 @@ def test_internet_only_reaches_the_engine_through_apply_settings(): check("internet only: armed on the core", engine.core.internet_only is True) apply_settings(engine, dict(DEFAULT_SETTINGS), lambda *_: None) check("internet only: disarmed again", engine.core.internet_only is False) + + +def test_a_one_way_filter_under_asymmetry_is_said_out_loud(): + """The one trap asymmetry brings that no counter would reveal. + + The traffic filter's ``out`` and ``in`` variants are applied IN THE DRIVER + (``filters.FILTER_DEFS``), so the other direction is never handed to this + process at all. Half the values the user just typed then describe traffic the + session cannot see - and nothing would say so: the summary would still list + them, the counters would show one direction's work, and the whole thing would + read as the tool ignoring its own form. + + Said once per apply rather than refused, which is the answer the two LAN + switches and the address-family pair already give: refusing would break a run + somebody meant, and impairing one direction on purpose is a legitimate ask. + """ + from beantester import DEFAULT_SETTINGS, apply_settings + from beantester.i18n import T + + warning = T("log.asym_one_way_filter") + + def lines_for(**overrides): + said = [] + apply_settings(BeanEngine(), dict(DEFAULT_SETTINGS, **overrides), said.append) + return said + + for one_way in ("out", "in"): + check(f"asymmetry with a {one_way}-only filter says so", + warning in lines_for(asym=True, filter=one_way, latency=200, + latency_up=20)) + check("a one-way filter WITHOUT asymmetry has nothing to warn about", + warning not in lines_for(filter="out", latency=200)) + check("asymmetry with the default two-way filter is silent", + warning not in lines_for(asym=True, latency=200, latency_up=20)) + # A protocol filter is not a direction, and warning there would be noise on a + # perfectly ordinary run. + check("a protocol filter is not a one-way filter", + warning not in lines_for(asym=True, filter="tcp", latency_up=20)) From 00c0cd55cf7691bcdd8d78e36892f4d5522b50df Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sat, 5 Sep 2026 13:54:46 +0200 Subject: [PATCH 6/6] docs(changelog): keep the asymmetry entry a release note Two problems the full suite found that the per-chunk runs could not. The entry ran to 143 words against the 100-word cap in test_no_user_facing_entry_grows_into_an_essay: this file carries the effect for a tester, and the reasoning belongs in CHANGELOG-INTERNAL.md, where it already is. The Changed heading had also been inserted in the middle of the Added list, which quietly moved two entries from the previous session (the refused-block checkbox and the connections_refused column) under a heading that describes neither. Both are additions and are back under Added. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b97845..258166b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Added -- **Different values for uploads and downloads.** A new "Asymmetry" card with a tick box: - leave it off and one set of numbers applies both ways, exactly as before. Tick it and the - fields higher up the page describe downloads only, while seven new fields describe uploads - - latency, jitter, spike chance and size, loss, corruption and duplication. The new fields start - as copies of what you already typed, so switching it on changes nothing until you edit them. - Real home and mobile lines are not the same in both directions, and an app that browses fine - can still struggle to send: a video call, a file upload, a game reporting your moves. On the - command line: `--asym` plus `--loss-up`, `--corrupt-up`, `--dup-up`, `--latency-up`, - `--jitter-up`, `--spike-prob-up` and `--spike-ms-up`. Profiles remember all of it, and the - session description says what the upload half is doing. +- **Different values for uploads and downloads.** A new "Asymmetry" card. Leave it off and + one set of numbers applies both ways, as before. Tick it and the fields above it describe + downloads only, while seven new ones describe uploads: latency, jitter, spike chance and + size, loss, corruption and duplication. They start as copies of what you already typed, so + nothing changes until you edit them. Reach for it when an app browses fine but struggles to + send: a video call, a file upload. On the command line: `--asym`, then `--loss-up`, + `--latency-up` and the rest. Profiles remember it. - **A blocked connection can be refused instead of ignored.** A new "Refuse blocked connections" checkbox in the Block card, and `--block-reject`. Without it a blocked