Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

## [Unreleased]

### Added

- **A "Reordered" figure on the Statistics page, and a `packets_reordered` column in the
stats CSV.** Jitter and the latency spike change the order packets go out in, but nothing
ever said whether that actually happened, and it depends on how busy the traffic is rather
than on the setting alone. Zero with jitter set now means the packets were too far apart to
overtake each other, instead of looking the same as an application that coped. An existing
stats CSV is rotated to a dated backup the first time the new column is written.

### Changed

- **The "Latency (ping)" card is now "Latency (ping) and packet order", and the spike
tooltip says what the spike is good for.** Delaying a slice of packets is how you test a
protocol that has to survive out-of-order delivery, and the field search matches names
rather than tooltips - so searching the Control page for "order" (or "kolejnosc") found
nothing at all, in either language. It now finds the card and all four fields in it.
- **A speed unit you can pick: `KB/s`, `Mbit/s` or `MB/s` (Settings window).** The Statistics
page, the chart, the session peak and average, and a grey readout beside Download and Upload all
follow it. It changes what you READ, never what you type - the limits stay in KB/s, which is what
a saved config file, the schedule, the scenarios, `--down`/`--up` and the NDJSON output carry.
`K` here is 1024 and a megabit is a decimal million, so 1024 KB/s reads as 8.39 Mbit/s, not 8.

## [0.6.0] - 2026-09-03

### Added
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,19 @@ KB/s. 0 = no limit. Ping is small packets, so a speed limit barely changes it -
use a file download. A positive value always limits something: an extremely small limit (below
1 B/s) is floored to 1 B/s, it does not silently turn into "no limit".

**Speed unit (Settings window)** - whether the Statistics page, the chart and the readout next to
these two fields show throughput in `KB/s`, `Mbit/s` or `MB/s`. It changes what you READ, never
what you type: the limits themselves stay in KB/s, because that is the number a saved
configuration file, the throughput schedule, the shipped scenarios, `--down`/`--up` and the NDJSON
output all carry. Pick a unit and a grey `(= 8.39 Mbit/s)` appears beside the field, so you can
type the number the tool wants while reading the number your link is sold in.

> **`K` here is 1024, and a megabit is a decimal million** - the two conventions do not cancel out,
> so 1024 KB/s is **8.39 Mbit/s**, not 8. That is the honest conversion, not a rounding error: a
> byte is 8 bits, `1024 x 1024 x 8 = 8 388 608` bits per second, and megabit means 10^6 bits
> everywhere a link is sold. Divide by 8.39, not by 8, when you want a limit to match an
> advertised speed.

**Buffer** - the capacity of the link buffer for a speed limit, in milliseconds (0 = unlimited
buffer). It sets how much queueing delay may build up on a rate-limited link before it starts
dropping the excess (bufferbloat). Without this buffer the token bucket could "run away" tens of
Expand Down Expand Up @@ -299,6 +312,19 @@ one. With the given probability it appends extra delay (ms) to a **single packet
momentary "lag" actually arrives. The chance is per packet and applies **in each direction**, so a
round trip hits it about twice as often as the number suggests.

**Testing out-of-order delivery** - this is what the spike pair is for, and it is the reason the
section is called "Latency (ping) and packet order". A spiked packet arrives after packets that
were sent later than it, so *Spike chance* and *Spike size* reorder traffic **without** smearing
every other packet's delay the way jitter does - which matters when the thing under test is a UDP
protocol that has to survive reordering on its own: a game, QUIC, telemetry, voice. Set a spike
size larger than the gap between your packets, or nothing will overtake anything.

Whether that actually happened is a separate question from whether it was configured, so the tool
counts it: **Reordered** on the Statistics page (and `packets_reordered` in the stats CSV). 0 with a
spike set means your traffic was too sparse for any packet to overtake another - not that the
setting was ignored. The count is per direction, so two busy connections can overtake each other
without either end seeing anything out of order.

**Impairments** - *Loss*: percentage of packets vanishing without a trace (5% is already a
clearly failing network). *Corruption*: percentage of packets with a flipped data bit - it affects
**only payload-bearing packets**. Packets with no data (e.g. pure ACK, SYN) have nothing to flip,
Expand Down Expand Up @@ -947,6 +973,7 @@ what `packets_seen` counted in the first place - so every row records it in `cap
| `dropped_overflow` | dropped because the tool's own queue was full (see the note on it below) |
| `corrupted` | packets whose payload was flipped |
| `duplicated` | extra copies queued |
| `packets_reordered` | packets that left the tool in a different order than they went in. 0 with jitter or a spike configured means the packets were too far apart for any of them to overtake another. Counted per direction, so two busy connections can overtake each other without either end seeing anything out of order |
| `dropped_syn` | TCP SYNs dropped ("connections that never open") |
| `dropped_mtu` | dropped for exceeding the max size (MTU black hole) |
| `dropped_nat` | dropped because the NAT mapping had expired |
Expand Down Expand Up @@ -1262,6 +1289,7 @@ the root, so all existing commands (README, reproduction reports, PyInstaller) w
bean_network_tester.py launcher + compatibility facade (re-exports the public API)
beantester/ the implementation package
core.py pure per-packet decision core (BeanCore)
damage.py how much a session damaged: drop reasons, loss/corruption shares
engine.py capture/inject threads, statistics (BeanEngine)
matchers.py filter expressions (list/range/!/>/</wildcard/re:) - shared
by the process, IP and port fields; a single source of truth
Expand Down
78 changes: 78 additions & 0 deletions beantester/damage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""What a session did to the traffic, as pure arithmetic over a stats dict.

Carved out of ``engine.py`` on 2026-09-04, and the reason is worth keeping: adding
the ``reordered`` counter pushed that module past the crowd band of the file-size
ratchet (``tests/test_code_shape.py``), whose answer is to move code out rather
than raise the number. These five names were the obvious passengers - none of them
touches a thread, a handle or a packet, they only READ a snapshot, and two of the
three callers were already importing them across a module boundary.

Nothing here imports anything: it sits at the bottom of the layering, below
``core`` and ``engine``, so the GUI, the repro report and the tests can all ask
"how much damage did this session do" without dragging the engine in.
"""

# Which counter a dropped packet lands in, by the reason BeanCore.decide() gave.
# Module level on purpose: written as a literal inside the capture loop it was
# rebuilt for every dropped packet, and a session set to 100% loss drops as often
# as it sees. It is also the SINGLE SOURCE for what counts as damage below.
DROP_BY_REASON = {"syn": "drop_syn", "mtu": "drop_mtu", "nat": "drop_nat",
"rst": "drop_rst", "lan": "drop_lan",
"internet_only": "drop_internet_only", "block": "drop_block",
"flap": "drop_flap", "rate": "drop_rate"}

# Damage the simulated link inflicted: every reason decide() can name, plus the
# unnamed default (the configured Loss). Derived from the map above so that a new
# impairment cannot quietly fall outside the figure - which is exactly how
# "Effective loss" came to read 0.0% through a session losing 90% to a speed
# limit. Guarded by
# test_engine.py::test_every_drop_counter_and_drop_reason_is_classified.
IMPAIRMENT_DROP_KEYS = (*dict.fromkeys(DROP_BY_REASON.values()), "drop_loss")

# Losses the TOOL caused, not the link: its delay queue filled up, the session
# ended with packets still parked in it, or re-injecting one failed outright.
# Deliberately NOT part of the loss figure.
# The README defines the term - traffic dropped above a speed limit is counted
# "because that is how a congested link behaves" - and tips.stat_shutdown says of
# these outright "They were not lost in the network". Both have their own tiles,
# and overflow additionally raises a log warning and a banner. Counting them here
# would also let the figure exceed 100%: the delay queue holds out-of-scope
# packets too, so with a narrow target it can drop more than were ever in scope.
TOOL_DROP_KEYS = ("drop_overflow", "drop_shutdown", "drop_send")


def impairment_loss_pct(stats):
"""Share of the traffic the tool was aiming at that the impairments killed.

Numerator: every drop ``decide()`` made. Denominator: packets that passed the
targeting gate (``scoped_seen``), not everything captured - with a target set,
other applications' traffic is watched but never impaired, so counting it only
dilutes the answer. Measured before this became one function: 50% loss with a
third of the traffic in scope reported 16.7% while the target application
itself saw 50.1%.

Both parts are per-packet and every drop counted here happened to a packet
that was in scope, so the result cannot exceed 100%. With no targeting set,
``scoped_seen == seen`` and this is simply the loss the session inflicted.

Takes a stats dict rather than an engine so the GUI can compute it from the
snapshot it already holds. A snapshot without ``scoped_seen`` (an older file,
a partial fake) falls back to ``seen``.
"""
scoped = stats.get("scoped_seen", stats.get("seen", 0))
if not scoped:
return 0.0
return 100.0 * sum(stats.get(k, 0) for k in IMPAIRMENT_DROP_KEYS) / scoped


def corruption_pct(stats):
"""Share of the targeted traffic whose payload was actually altered.

Same denominator as ``impairment_loss_pct``, for the same reason. ``corrupted``
counts successful payload flips only - a packet with no payload (a bare ACK)
has nothing to corrupt and is not counted.
"""
scoped = stats.get("scoped_seen", stats.get("seen", 0))
if not scoped:
return 0.0
return 100.0 * stats.get("corrupted", 0) / scoped
Loading
Loading