This post is also available in:
English
polski (Polish)
// I will redo this in future. (V0.2 documentation. )
The window
The window adapts to the screen and to system scaling (DPI). The initial size is computed from the resolution (fits on 1366×768 and grows on Full HD / 2K / 4K), and every dimension – column widths, table row heights, chart margins, text wrapping – scales with the font. The program declares itself Per-Monitor-V2 DPI aware, so moving the window to a second monitor with different scaling does not blur the interface.
Window size and position, the selected tab, language, collapsed sections, the log/tabs split and table sort order are remembered in bean_network_tester_ui.json (next to the profiles). The saved geometry is validated before use – if the monitor is gone, the window returns to the centre of the current screen.
- Control – all impairment settings, grouped into collapsible sections (the collapsed state is remembered). On a wide window the sections lay out in two columns (instead of one narrow column and an empty right half), so there is far less scrolling. The whole tab scrolls – including with the mouse wheel.
- Statistics – three sub-tabs, so nothing is clipped on small screens:
- Live – counters (packets, lost, corrupted, torn down…) and a throughput chart; the counter grid picks its column count to fit the window width. An “Export CSV” button.
- Session – seed, duration, data used, peaks + “Mark bug”, “Save repro report”, “Copy CLI command” buttons.
- Events – the event log (START/STOP/CHANGE/SCENARIO/BUG/RESET).
- Connections – a view of which IP:port the tested system talks to. Columns: process, protocol, remote IP, ports, packet count, KB, duration and time since last activity. Plus a search box (debounced, so it does not churn the table on every keystroke), click-to-sort headers, “Freeze” (rows stop escaping from under the cursor) and a right-click menu: copy row / IP, “Target this process”, “Limit to this IP:port” – fills the filter fields with one click. The table is virtualised: it draws only the rows actually on screen, so scrolling is instant whether it holds 400 rows or a few hundred thousand. The old hard 400-row limit is gone – how many to show is set with the “Row limit” field (Tables section; 0 = no limit, default 50 000).
- At the bottom: START/STOP, Apply changes and Load/Save file, with the log beneath. This bar is anchored to the bottom edge – no tab can cover it.
When changes take effect
Nothing applies itself. A preset, profile, LAN mode and a loaded config file only fill the form. They reach a running session only through “Apply changes” – the button highlights when the form differs from what the engine is actually doing. The bar under the title tells you what you are looking at:
| Prefix | Meaning |
|---|---|
Preview: |
the app is stopped – this describes what will happen after START |
Active: |
this is exactly what is applied to traffic right now |
Unapplied changes: |
the form was changed – click “Apply changes” |
While a session runs two elements are locked (they unlock on STOP): the traffic filter (applied only at START) and the language selector (changing language rebuilds the whole UI). The STOP button is red – it cannot be confused with START.
Inactive fields (e.g. “Period”/”Down percentage” when “Enable” is unchecked) are greyed out together with their labels – you can see at a glance what is active and what is not.
Keyboard shortcuts
| Shortcut | Action |
|---|---|
F5 |
START / STOP |
Ctrl+Enter |
Apply changes |
Ctrl+S / Ctrl+O |
Save / Load config file |
Ctrl+L |
Clear the log |
Field validation
Numeric fields are checked live, together with their range (e.g. loss 0-100%, latency 0-600000 ms): a bad field turns red and the reason appears under the section. The same applies to filter expressions. The same range applies in the CLI – --loss 250 is now an error, not a silent clamp to 100%.
All options explained
Traffic to modify – which traffic to capture at all. Options: both directions (TCP+UDP+ICMP), outbound only, inbound only, TCP only, UDP only, ICMP only (ping). Every filter covers IPv4 and IPv6. (If ping “does not react”, it is almost always because the chosen filter does not include ICMP.)
Note: port presets (“DNS/HTTP/HTTPS only”) do not exist – to narrow by port use the Port field in “Target destination”, which understands lists, ranges and exclusions (
80,443,8000-8100,!53). Two places deciding about ports, with different semantics, would only confuse.
Traffic filter is applied at start, so while running it is locked – to change it, stop (STOP), pick another and start again (START).
LAN mode – a “LAN mode (local network only, no internet)” checkbox. It rejects traffic to/from public (internet) addresses and passes the local network: 10.0.0.0/8, 172.16-31.x, 192.168.x, loopback, link-local and CGNAT. It simulates “LAN works, internet is down” – a test of how the app behaves without internet access (e.g. no gateway/WAN, a captive portal).
Target process – narrow the effect to chosen apps: process name (e.g. chrome.exe), PID, a comma-separated list, PID range, wildcard or regular expression – see Filter syntax. The rest of the machine’s traffic stays untouched. Empty field = all traffic. Requires psutil.
Speed limit – maximum throughput separately for download (inbound) and upload (outbound), in KB/s. 0 = no limit. Ping is small packets, so a speed limit barely changes it – to test the limit 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”.
Delay (ping) – Latency: how many ms to add to every packet (raises ping). Jitter: random variation of the delay (+/- ms), which makes ping jump and reorders packets. Two things worth knowing: (1) jitter adds an independent random delay to each packet, so packets can overtake one another in the queue – jitter inherently reorders packets (a real network does the same). (2) Negative swings are clipped to zero, so when jitter is larger than latency the average added delay rises above latency itself (e.g. latency 0, jitter 50 ms gives ~half the packets with no delay and a mean of ~12 ms, not 0). When latency is larger than jitter the effect is negligible.
Impairment (%) – 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, so they pass untouched and are not counted as corrupted. Duplication: percentage of packets sent twice.
Link flapping – cyclic total loss of traffic: every Period seconds the link is dead for the given percentage of the time. Simulates a flickering connection.
Advanced (NAT / connections):
- Target destination (IP/port) – impair only traffic to/from chosen servers. Both fields accept lists, ranges, CIDR, wildcards, comparisons, exclusions and regular expressions – see Filter syntax. E.g. IP
10.0.0.1-10.0.0.50,!10.0.0.7, port80,443,8000-8100. Empty = any. - TCP SYN drop (%) – percentage of dropped connection-opening packets; simulates a connection that will not establish (retry testing) – useful for tests from behind NAT.
- Max size (MTU) – drop packets larger than N bytes; reproduces an “MTU black hole” from tunnels/VPN/behind NAT (small ones pass, large ones vanish). 0 = disabled.
- Latency spike – with the given probability append extra delay (ms) to a single packet; reproduces momentary “lag”.
- NAT timeout – if a connection is silent for more than N seconds, the next inbound packet is dropped (the mapping “disappears”); a keep-alive test. 0 = disabled.
- TCP tear-down (RST) – percentage of connections abruptly torn with an RST packet; forces reconnects. TCP only (RST is a TCP concept; UDP cannot be “torn” – use loss or link flapping). The Tear down TCP now button resets all active TCP connections for ~3 s.
- Schedule – throughput that changes over time:
time:download:uploadin KB/s, comma-separated. E.g.2:100:0, 2:500:0= 2 s at 100 KB/s, then 2 s at 500, in a loop. When the schedule is non-empty it overrides the fixed “Download/Upload” fields – the GUI greys them out and says so explicitly.
Session:
- Duration (s) – after this many seconds the program stops itself (exactly as if you clicked STOP): impairment disappears, the driver is released.
0= runs until STOP (the previous, default behaviour). The CLI equivalent is--duration; like the traffic filter, it is taken into account only at START (“Apply changes” does not touch it). The value is saved in the config file and in the reproduction command.
Repeatability and scenario:
- Seed – set any number so every run randomises the same way (a bug becomes reproducible). Empty = different every time.
- Scenario – a JSON file that changes settings in real time (e.g. after 10 s add ping, after 20 s tear down). “Loop” replays it endlessly. Examples in
scenarios/(cafe Wi-Fi, mobile LTE->3G, congested VPN, failing DNS, overloaded game server, upload dropped midway).
Profiles – ready presets sorted from best (top) to worst (bottom): Perfect network, Good Wi-Fi, 5G network, LTE/4G network, Home DSL, Weak Wi-Fi, Cafe (crowded Wi-Fi), 3G network, International roaming, Satellite link, 56k modem, Terrible network – plus your own (saved under a name). The program always starts on “Perfect network” (nothing is impaired until you set something). Built-in presets cannot be deleted – the “Delete” button is disabled for them.
In the CLI (--preset) a preset can be given by its canonical id or a name in any UI language (case- and Polish-diacritic-insensitive – "Perfect network" works too). Ids: presets.perfect, presets.good_wifi, presets.5g,
presets.lte, presets.dsl, presets.weak_wifi,
presets.cafe, presets.3g, presets.roaming, presets.satellite,
presets.modem56k, presets.terrible.
Filter syntax (process / IP / port)
Three fields – Target process, IP and port (in “Target destination”) – speak the same mini-language. The same syntax will be used in every future filtering field the tool grows. It works identically in the GUI and the CLI (--target, --dst-ip, --dst-port).
Building blocks
| Notation | Meaning | Example |
|---|---|---|
a,b,c |
list – any of the values matches | 80,443 |
a-b |
range, both ends inclusive | 8000-8100, 10.0.0.1-10.0.0.50 |
> < >= <= |
comparison (numeric; for IP by address value) | >1024, <=80, >10.0.0.5 |
! |
exclusion – “different from” | !53 |
* ? |
wildcard (* = any run, ? = one character) |
chrome*, 192.168.1.* |
re: |
regular expression (Python re, case-insensitive) |
re:^chrome\.exe$ |
x.x.x.x/n |
CIDR (IP field only) | 192.168.1.0/24, 2001:db8::/32 |
An empty field = everything (no filtering). Spaces around commas and terms are ignored.
How terms combine
matches = (no positive terms OR any positive term matches) AND no "!" term matches
In other words: positives add up (OR), exclusions subtract (AND NOT). Term order does not matter – 80,!53 means exactly the same as !53,80.
| Entry | Means |
|---|---|
| (empty) | everything |
443 |
port 443 only |
80,443 |
80 or 443 only |
!53 |
everything except 53 (there is no positive term) |
!53,!443 |
everything except 53 and 443 |
1000-2000,!1500 |
1000-2000 but without 1500 |
>1024,!3389 |
everything above 1024 but not 3389 |
80,443,8000-8100,>9000,!8080 |
80, 443, 8000-8100 and everything above 9000 – except 8080 |
The “Target process” field
A term can be a name or a PID – you can mix them in one field.
- A name (without
*and withoutre:) works as a case-insensitive substring:chromecatcheschrome.exeandchromedriver.exe. (It has always worked this way – kept on purpose so old configs and profiles still work.) - A PID – a bare number (
12345), a range (1000-2000) or a comparison (>1000). - Wildcard and
re:match the process name. - Comparisons
><>=<=only make sense for a PID (numbers).>chromeis an error – the tool says so explicitly instead of silently matching nothing.
chrome.exe all processes with "chrome.exe" in the name
chrome, !chromedriver chrome but NOT chromedriver
chrome.exe, firefox.exe two apps at once
12345 a specific PID
12345, 6789 two PIDs
1000-2000 all processes with a PID in that range
>1000 all processes with a PID > 1000
firefox* a name starting with "firefox"
re:^(chrome|firefox)\.exe$ exactly chrome.exe or firefox.exe
firefox, 12345 a name and a PID in one field
All matching processes contribute their local ports – targeting covers the union of their ports. The list is refreshed during the session (every ~2 s), so newly opened connections of the process are caught.
The “IP” field
IPv4 and IPv6 are supported. A rule never matches an address from the other family – an IPv4 rule will not catch an IPv6 address and vice versa (you can safely mix them in one field).
1.2.3.4 one address
1.2.3.4, 8.8.8.8 two addresses
10.0.0.1-10.0.0.50 a range (both ends inclusive)
10.0.0.1-10.0.0.50, !10.0.0.7 a range with a hole
192.168.1.0/24 a whole subnet (CIDR)
192.168.1.* the same via wildcard
!8.8.8.8 everything except 8.8.8.8
>10.0.0.5 addresses "greater" than 10.0.0.5
2001:db8::/32 an IPv6 subnet
2001:db8::1-2001:db8::ff an IPv6 range
10.0.0.0/8, 2001:db8::/32 IPv4 and IPv6 in one field
re:^10\. anything starting with 10.
Address notation does not matter: 2001:0db8:0000:0000:0000:0000:0000:0001 and 2001:db8::1 are the same address to the tool.
The “port” field
443 one port
80,443 a list
8000-8100 a range (both ends inclusive)
>1024 high ports
<=1024 privileged ports
!53 everything except DNS
80,443,8000-8100,!8080 a mix
The allowed range is 0-65535; 99999 is an error, not a silent skip.
Regular expressions (re:)
The re: prefix is mandatory – without it a term is a plain value. That way chrome.exe is a file name (a dot is a dot), not a regex pattern.
| Pattern | Catches |
|---|---|
re:^chrome |
names starting with chrome |
re:^chrome\.exe$ |
exactly chrome.exe (not chromedriver.exe) |
re:^(chrome|firefox)\.exe$ |
exactly chrome.exe or firefox.exe |
re:(node|python) |
names containing node or python |
re:^\d+$ |
(in a port/PID field) a bare number |
re:^10\. |
(in an IP field) addresses starting with 10. |
!re:^chrome |
everything except names starting with chrome |
Patterns are case-insensitive and look for a match anywhere (re.search) – if you want a start-to-end match, use ^ and $.
A comma inside a regex must be escaped with a backslash (\,), because a comma separates terms:
re:^ch.{1\,8}e\.exe$ correct - {1,8} with a backslash before the comma
re:^ch.{1,8}e\.exe$ WRONG - it is split into "re:^ch.{1" and "8}e\.exe$"
Cases that trip people up
chromealso catcheschromedriver, butchrome.exedoes not. A bare name is a substring: the text “chrome” appears inchromedriver.exe, but the text “chrome.exe” does not (there it ischrome+driver.exe). Sochrome.exe-> onlychrome.exe;chrome->chrome.exeandchromedriver.exe.chrome*is broader than it looks. The star is “any run”, sochrome*catcheschromedriver.exeexactly likechrome. Want exactly one app? Usere:^chrome\.exe$or add an exclusion:chrome, !chromedriver.8*in a port field is a wildcard on the number’s text, so it catches8,80,8080and8443– but not443. You almost always meant a range:8000-8999. Use port wildcards deliberately.80,443,!8080is just80,443. An exclusion that cuts nothing is not an error – it is simply useless.!53also covers traffic with no port (e.g. ICMP/ping). “Everything except 53” literally means “everything that is not port 53”, and an ICMP packet is not port 53. If you want only TCP/UDP, narrow the Traffic filter.- An empty term is an error, not “everything”:
80,,443and80,!are rejected. An empty whole field means “everything”. - IP and port combine with AND.
IP=10.0.0.0/8+port=443means “traffic to the 10.x network and at the same time on port 443″. Want “either/or”? Leave the other field empty and do two runs. >chromeis an error. Comparisons work on numbers (PID), not names.2000-1000is an error (reversed range), not an empty set.- A wildcard is not a regex. In
chrome*the star means “any run”; inre:chrome*it means “the lettererepeated 0+ times”. If you writere:, you write a regex.
Every syntax error is reported immediately: in the GUI the field turns red with the reason beneath it (in the UI language), and the CLI ends with a readable error: ... – never a silent “nothing works”.
Statistics (what the counters mean)
The throughput chart has a Y axis with values (KB/s), a grid, a “nicely” rounded scale and current down/up readouts in the corner. Download/Upload (KB/s live), Packets (how many passed), Queued (waiting – grows with delay/limit), Lost, Corrupted, Duplicated, Buffer overflow (dropped when the tool is overloaded), Rate-limit drop (dropped by a full speed-limit buffer – counted separately from loss and from “Buffer overflow”), SYN dropped, MTU dropped, NAT expired, RST torn, LAN: internet cut off, RST sent.
Reproducing a bug (the “Session and reproduction” panel)
Designed so that after a bug you can recreate exactly the same conditions:
- Seed (effective) – even if you leave the Seed field empty, the program draws and remembers a concrete seed. Type it into the Seed field later and run again to get the same draws.
- Repeatable flapping – the link-outage pattern is measured from the session start, so with the same settings it repeats identically between runs (it does not depend on the system clock).
- What the seed reproduces – the seed reproduces the engine’s decisions (which packets get dropped, corrupted, duplicated, by how much delayed), not the packet count. The traffic that crosses the link depends on what your apps and system do at that moment, so two runs with the same seed give the same proportions (e.g. 15.8% loss in both) but not identical counters to the packet. For CI comparisons use rates (%), not raw packet counts.
- Start / Duration / Effective loss / Queue peak / Down-up peak – a quick picture of the run.
- Data used – Downloaded / Uploaded / Total (MB) cumulatively from start plus the session’s average throughput; you know at once how much data the app used. (The report also has “attempted MB” – how much the app wanted to send before loss/limits are subtracted.)
- Event log with timestamps: start, setting changes, scenario steps, tear-downs, and your bug markers – with sorting on a column-header click.
- Mark the moment of a bug – click exactly when you see the bug; it inserts a timestamped marker into the log.
- Save reproduction report – a single JSON file with the lot: seed, all settings, counters, metrics, event log, connections and a ready CLI command that recreates the conditions.
- Copy CLI command – straight to the clipboard:
BeanNetworkTester.exe --seed ... --loss ... --duration ...(the command adapts to the build: from the repository you getpython bean_network_tester.py ...).
Configuration file
The “Save/Load file” buttons write all settings to JSON. The same file works in the CLI via --config. Precedence order: defaults < file < preset < flags.
Command-line mode (CLI)
The CLI runs from the same BeanNetworkTester.exe as the GUI: launching it with any argument starts the text mode (no window, no tkinter), and with no arguments – the GUI. Messages and --help are always in English.
:: GUI
BeanNetworkTester.exe
:: CLI (from the same exe)
BeanNetworkTester.exe --loss 5 --latency 100 --down 1024 --target chrome.exe
BeanNetworkTester.exe --preset "3G network" --duration 60
Working from the repository (no build)? Everywhere replace
BeanNetworkTester.exewithpython bean_network_tester.py– all flags and exit codes are identical.
Exit codes (the CI/CD contract)
Every way of ending has its own code – a pipeline does not need to parse text:
| Code | Name | When |
|---|---|---|
0 |
ok | the session ran and every check passed |
1 |
runtime | could not start (no pydivert, driver, engine failure) |
2 |
usage | bad command line (unknown flag, wrong type) – argparse’s code |
3 |
config | invalid settings: expression, schedule, range, preset, config file |
4 |
scenario | the scenario file is missing or malformed |
5 |
io | an artifact could not be written (repro report, saved config) |
6 |
assertion | the run succeeded but --min-packets / --fail-on-no-traffic did not pass |
7 |
permission | administrator rights are required and missing |
130 |
interrupted | Ctrl+C (SIGINT) |
143 |
terminated | SIGTERM (job cancellation, docker stop) |
BeanNetworkTester.exe --help prints the same codes.
Output: logs on stderr, data on stdout
- stderr – the human log, prefixed
[bean](start, seed, errors, stop reason), - stdout – data: report lines, and with
--format jsonNDJSON (one JSON object per line: successivesampleobjects, and a finalsummarywith the exit code, seed and repro command).
BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson
All CLI parameters
Link impairment
| Flag | Unit | Description |
|---|---|---|
--loss |
% | percentage of dropped packets |
--corrupt |
% | percentage of packets with a flipped bit |
--dup |
% | percentage of packets sent twice |
--latency |
ms | fixed delay added to every packet |
--jitter |
ms | random delay variation (+/-) |
--down --up |
KB/s | throughput limit (0 = no limit) |
--spike-prob --spike-ms |
% / ms | with the given probability append extra delay |
--syn-drop |
% | percentage of dropped TCP SYN packets |
--max-size |
B | “MTU black hole” – drop packets larger than N bytes (0 = off) |
--nat-timeout |
s | after N s of silence the NAT mapping “disappears” (0 = off) |
--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 |
--lan-mode |
– | LAN mode: cut off the internet (public addresses), keep the local network |
Targeting (all three accept the full filter syntax: lists, ranges, !, >, <, >=, <=, wildcards, re:, and --dst-ip additionally CIDR)
| Flag | Description | Examples |
|---|---|---|
--target |
processes: name, PID, PID range, wildcard, regex | --target chrome.exe--target "chrome,!chromedriver"--target ">1000" |
--dst-ip |
remote IP addresses (IPv4 and IPv6) | --dst-ip 1.2.3.4--dst-ip "10.0.0.1-10.0.0.50,!10.0.0.7"--dst-ip "192.168.1.0/24"--dst-ip "2001:db8::/32" |
--dst-port |
remote ports (0-65535) | --dst-port 443--dst-port "80,443,8000-8100"--dst-port "!53"--dst-port ">1024" |
--filter |
which traffic to capture at all (IPv4 + IPv6): both,out,in,tcp,udp,ping |
--filter tcp |
Run and reporting
| Flag | Description |
|---|---|
--preset NAME |
preset by canonical id or a name in any UI language |
--config FILE / --save-config FILE |
load / save settings (JSON, shared with the GUI) |
--scenario FILE --loop |
a timeline scenario (JSON) and looping it |
--seed N |
randomness seed – the same run can be repeated |
--duration N |
run time in seconds (0 = until Ctrl+C). The same field is in the GUI (“Session”) |
--row-limit N |
a GUI-only setting: max rows in the tables (0 = no limit; default 50 000). In headless CLI (no window) it does nothing – it is only saved to the config file and takes effect when that config is opened in the GUI. The “Row limit” field’s equivalent |
--interval N |
how often to report, in seconds (must be > 0) |
--log-conns |
print the observed connections at the end |
--repro-out FILE |
save a reproduction report (JSON) |
--simulate |
synthetic traffic instead of WinDivert (test with no Windows, no driver, no admin) |
--gui |
force the GUI |
--version |
print the version and exit |
Output and diagnostics
| Flag | Description |
|---|---|
-v, --verbose |
log what the program does: effective settings, compiled filters, resolved process ports, scenario steps, driver open/close |
-q, --quiet |
errors only: no log and no periodic reports |
--log-level {error,warn,info,debug} |
explicit log level (overrides -v/-q) |
--log-file FILE |
also append the log (and reports) to a file – a ready CI artifact |
--format {text,json} |
stdout format: human text or NDJSON for a pipeline |
For CI/CD
| Flag | Description |
|---|---|
--dry-run |
check the config and exit (does not touch the driver, passes no traffic) – ideal for validating config files in a pipeline |
--print-config |
print the effective settings (after defaults < file < preset < flags) as JSON and exit |
--min-packets N |
exit with code 6 if fewer than N packets were caught |
--fail-on-no-traffic |
shorthand for --min-packets 1 – catches a filter that caught nothing |
--doctor |
check the environment (admin, pydivert, WinDivert driver state, %TEMP% leftovers) and exit |
--cleanup-driver |
unload a stuck WinDivert driver (frees the locked .sys file without a system restart) and exit |
Precedence order: defaults < --config < --preset < flags. Full list: BeanNetworkTester.exe --help.
Targeting examples
:: only the browser, but not its test driver
BeanNetworkTester.exe --loss 10 --target "chrome,!chromedriver"
:: only HTTPS traffic to a test server and its backup address
BeanNetworkTester.exe --latency 300 --dst-ip "10.0.0.5,10.0.0.9" --dst-port 443
:: the whole test subnet except one host, on application ports
BeanNetworkTester.exe --down 128 --dst-ip "10.0.0.0/24,!10.0.0.1" --dst-port "8000-8100"
:: everything EXCEPT DNS (so name resolution works while the rest breaks)
BeanNetworkTester.exe --loss 20 --dst-port "!53"
:: high-PID processes, traffic to IPv6
BeanNetworkTester.exe --jitter 80 --target ">1000" --dst-ip "2001:db8::/32"
The --simulate mode (test with no Windows and no admin)
A preview on synthetic traffic – needs neither WinDivert nor privileges:
BeanNetworkTester.exe --simulate --down 500 --loss 10 --duration 4 --interval 1
Repeatability and scenarios from the CLI
BeanNetworkTester.exe --simulate --seed 42 --loss 20 --duration 10
BeanNetworkTester.exe --simulate --scenario scenarios/cafe-wifi.json
The seed guarantees identical per-packet decisions for the same packet sequence. Scenario steps are cumulative (each patches the state), and action: reset_tcp tears down TCP connections at that moment (the old name reset_now still works). The scenario file is validated – random JSON ends with a readable error, not a “scenario with 0 steps”.
Every CLI run ends by printing the effective seed and a ready command to reproduce it, and --repro-out file.json saves the full reproduction report.
CI/CD recipes
1. Link degradation in the background of E2E tests (GitHub Actions, Windows)
The tests run at 300 ms delay and 5% loss; the shaper stops itself after 120 s, so no “stuck” step leaves a broken network on the agent.
- name: Start the network shaper (background, self-stopping)
shell: pwsh
run: |
$p = Start-Process -FilePath dist\BeanNetworkTester\BeanNetworkTester.exe `
-ArgumentList '--latency','300','--loss','5','--duration','120',
'--dst-port','443','--fail-on-no-traffic',
'--format','json','--log-file','shaper.log' `
-RedirectStandardOutput shaper.ndjson -PassThru
"SHAPER_PID=$($p.Id)" >> $env:GITHUB_ENV
- name: Run the E2E suite under bad network
run: npm run test:e2e
- name: Stop the shaper and check it actually impaired something
if: always()
shell: pwsh
run: |
Stop-Process -Id $env:SHAPER_PID -ErrorAction SilentlyContinue
Get-Content shaper.ndjson | Select-Object -Last 1
Note: run the background process with
--duration– it is the safety net. Even if the “Stop” step never runs (job cancellation, timeout), the session closes itself, the driver is released and the agent gets its normal network back.
2. Config validation in pre-commit / PR (no driver, no admin)
BeanNetworkTester.exe --dry-run --config profiles/bad-3g.json
Code 0 = the file is valid; 3 = there is an error (with a readable message on stderr).
3. A short, repeatable run with an artifact
BeanNetworkTester.exe --preset presets.3g --seed 42 --duration 60 ^
--repro-out repro.json --format json --fail-on-no-traffic > run.ndjson
The artifacts (run.ndjson, repro.json) are enough to recreate the conditions 1:1 – repro.json contains a ready cli_command.
4. Cleaning up the agent environment
BeanNetworkTester.exe --doctor
BeanNetworkTester.exe --cleanup-driver
Gotchas (read before filing a bug)
--durationis a safety net, not just convenience. Without it a session lasts untilCtrl+C/ STOP. In CI always pass--duration.- No traffic = a green run. If the filter catches not a single packet, the program works correctly and exits with code
0. Want that to be an error ->--fail-on-no-traffic. - The traffic filter and duration take effect only from START (as in the GUI): “Apply changes” does not touch them.
- After STOP the packets waiting in the delay queue are dropped. At
--latency 5000that can be quite a few packets at once – this is not a leak, it is the end of the session. - The speed limit has a buffer (
--buffer, default 2000 ms). With an offered load above the limit, once the buffer fills the excess goes as “Rate-limit drop” (a separate counter) – this is clogged-link behaviour, not a bug. Raising the limit mid-session takes effect only after the buffer drains (up to ~--bufferms). Want the old unbounded buffer (packets instead of drops, at the cost of growing delay)? Set--buffer 0. –> W.I.P, not in 0.2 --dst-port "!53"also catches traffic with no port (e.g. ICMP): a packet with no port “is not port 53”.- A bare process name is a substring:
--target chromealso catcheschromedriver.exe. Precisely:--target "re:^chrome\.exe$". - Ranges are inclusive on both ends (
80-80= one port), as in nmap/iptables. - The CLI is always in English, regardless of GUI and system language.
-qreally is quiet: on success it prints nothing. Read the result from the exit code (or add--format json).- Running without admin (and without
--simulate) ends with code7– not “silence”.
How it works (in brief)
The core BeanCore.decide() is a pure function that decides a packet’s fate in order: targeting -> NAT -> RST -> flapping -> MTU -> SYN -> loss -> corruption -> delay/jitter/spike -> throughput limit (a token bucket with a bounded buffer, optionally from the schedule) -> duplication. The capture thread reads packets and runs the decision; the re-inject thread sends them at the chosen moment. All randomness goes through one generator (optionally seeded).
Notes and limitations
- It modifies traffic matching the filter; for narrower tests use “Target process” or “Target destination”.
- Ping = ICMP: to affect it, pick a filter that includes ICMP.
- A speed limit is hard to see on ping (small packets) – test with a file download.
- Real RST capture and injection only work on Windows with WinDivert; the logic is confirmed by tests that run everywhere.
- A tool for testing your own applications and networks.
Behaviours worth knowing
- The schedule loops – after the last step it returns to the first (
2:100:0, 2:500:0alternates 2 s at 100 KB/s and 2 s at 500 KB/s, endlessly). Applying a schedule mid-session starts the cycle from the first step. - The schedule takes precedence over a fixed limit – when the “Schedule” field is non-empty the “Download/Upload” (KB/s) values are ignored, because throughput comes from the schedule steps.
- The schedule is optional but must be valid – an empty field = no schedule, while a bad entry (e.g.
1:100,2:abc:0) is reported as an error: the GUI will not start the session and the CLI ends with a message. Nothing is silently skipped. - Filter expressions are validated – an invalid entry in the process/IP/port field (e.g.
999.1.1.1,2000-1000,>chrome,re:[) is reported as an error instead of silently doing nothing: in the GUI the field turns red with the reason beneath it, and the CLI ends with anerror: ...message. Address comparison is notation-insensitive (a short and a full IPv6 form are the same address). - Positives add up, exclusions subtract –
80,443,!8080means “80 or 443 but not 8080”, and!53alone means “everything except 53”. Term order does not matter. Details and edge cases: Filter syntax. - Ranges are inclusive on both ends –
8000-8100covers 8000 and 8100 (as in nmap/iptables), and80-80is exactly one port. - IP and port in “Target destination” combine with AND – setting both fields narrows to traffic that satisfies both conditions at once.
- A very low speed limit = real loss – packets waiting in the queue have a cap (20 000). At an extremely low limit and heavy traffic the excess is dropped and counted in the “Buffer overflow” statistic – this is intended (that is how an overloaded link behaves), but remember the effective loss can then be higher than the set “Loss” percentage.
- An empty Seed field = a random seed – the program still draws a concrete value and shows it in the session panel. In config files the value
-1means “randomise”, so-1cannot be used as a plain seed (any other number, including a negative one, works normally). - Process targeting includes child processes – a socket belongs to the target if the process that opened it, or any of its ancestors, matches. That is why
chrome.exe(or the browser window’s PID) also catches its network process – which is the one holding all the connections. An explicit exclusion wins:chrome, !chromedriverwill not pull inchromedrivervia a parent. - Process targeting is a race with the system (and stays one) – WinDivert gives a packet, not a PID, so we resolve the process from the socket table by local port. The table is refreshed ~3x per second and additionally at once when an unknown port appears, so a freshly opened connection starts being impaired within tens of ms. The very first packet of a brand-new connection may slip through – that is a limit of the method, not a bug.
- Targeting that catches nothing breaks nothing – if no running process matches the expression, traffic passes untouched. The program says so explicitly (a red note under the field and a log entry), because “a run in which nothing broke” looks identical to “the app held up”.
- A bare process name is a substring –
chromealso catcheschromedriver.exe. This is kept on purpose (compatibility with old configs); for precision reach forre:^chrome\.exe$or the exclusionchrome, !chromedriver. - Statistics and Connections show ALL captured traffic – whatever the “Traffic to modify” filter passes. Targeting (process / IP / port) decides only what gets broken, not what is visible in the tables and counters.
- The speed limit shapes the AVERAGE – the token bucket lets short bursts through, so the “Download/Upload peak” (averaged over a 1 s window) can be a touch higher than the set limit. Duplicates count against the limit (the second copy travels the link too).
- The window has a maximum size and cannot be maximised – the layout (two columns + the log bar) stops making sense stretched to 4K, so the size is capped and the maximise button removed.
- Duration counts from START – changing the field mid-session does nothing (like the traffic filter); once the limit is reached the program simply STOPs and leaves the results on screen.
- STOP drops the packets waiting in the delay queue – the end of a session is immediate. At a large
latencythis shows as a one-off “gap”; it is not a bug. - A failure mid-session always ends with the network restored – if the capture thread dies, the engine STOPs itself and releases the driver (fail-open), instead of holding an open handle no one reaches (this was a real path to “the user suddenly has no internet”). The reason goes to the log and the event log.
- Closing the program releases the WinDivert driver – not after every session (a session restart should be instant), but once, on exit. As long as the driver is loaded, the kernel holds the
WinDivert64.syssitting next to the exe open – and then the program directory cannot be removed, even when it looks empty (Windows lets you delete a file with an open handle: it vanishes from the list but stays in pending delete and blocks the directory). If something is left over, the rescue without a restart isBeanNetworkTester.exe --cleanup-driver(orsc stop WinDivert+sc delete WinDivert). - “Duration” and “Traffic to modify” are taken into account only at START – which is why during a session both are locked (an editable field that does nothing is worse than a greyed-out one).
Support the project
The project is developed by DonislawDev and is free. If the tool saves you time and you want more features to appear, you can voluntarily support its development:
https://donislawdev.com/support/
Support is entirely optional – the full functionality works without it. In the program a “Support the project” button leads there (the window header).
Privacy: no telemetry
Bean Network Tester sends no data anywhere. It has no telemetry, no update checks and no network client of any kind. The tool captures network traffic on your computer – and that data never leaves it. The only outbound connection the program can make is opening the support page in your browser, and only when you click the corresponding button yourself.
PS: I love beans.
Third-party components
The program uses libraries by other authors, under their own licenses – among them WinDivert and PyDivert under LGPLv3, psutil (BSD), CPython (PSF), Tcl/Tk and the PyInstaller bootloader. The full list, versions and source addresses are in THIRD-PARTY-NOTICES.md, and the full license texts are in the licenses/ directory. From within the program: --license (CLI) or the About button in the interface.
The LGPL libraries (WinDivert, PyDivert) can be replaced with your own interface-compatible versions – which is why the program is built as onedir, with the driver and libraries sitting next to the .exe file. Nothing in the Bean Network Tester license restricts the rights arising from those components’ licenses.