From fe9260f917fd0f312f228680bd753f1fa4940c52 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Tue, 13 Oct 2026 10:02:14 +0200 Subject: [PATCH] Add a download limit for localhost on Linux WebPageReplay tests run on loopback, where throttle could only add latency, so pages always downloaded at memory speed. That hides regressions that cost bandwidth, like a heavier HTML page or a larger image. The limit is for all traffic on loopback, because the browser and the server both send on it. Requests and ACKs are small, so it works as a download limit. A profile together with --localhost now uses the profile's download and RTT; before, it throttled the real network interface instead. Assisted-by: Claude Opus 5.5 noreply@anthropic.com --- README.md | 9 +++++++++ bin/index.js | 10 ++++++++-- lib/index.js | 11 ++++++++++- lib/localHostTc.js | 8 ++++++-- test/start.sh | 12 ++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b4c10c1..f3ad6c0 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,15 @@ This is useful if you test a local web server or run [WebPageReplay](https://git throttle --rtt 200 --localhost ``` +On Linux you can also set a download limit, or use a profile: + +``` +throttle --rtt 200 --down 5000 --localhost +throttle --profile cable --localhost +``` + +The browser and the server both send on the loopback interface, so the limit is for all traffic on it. Requests and ACKs are small, so it works as a download limit. Upload limits are not supported on localhost, and a profile only uses its download and RTT values. + On Linux, throttle also sets the MTU of the loopback interface to 1500, the packet size of real networks. With the loopback default (65536), the receive window of the browser limits the transfer, and the number of round trips changes from run to run. Stop sets the MTU back to 65536. If you do this on a host and not in a container, all local traffic uses the smaller packets until you stop. ## Stop adding delay on localhost diff --git a/bin/index.js b/bin/index.js index 5ecf8a3..2b767b6 100755 --- a/bin/index.js +++ b/bin/index.js @@ -124,7 +124,9 @@ async function run(argv) { if (argv.profile in profiles || argv._[0] in profiles) { options = profiles[argv.profile || argv._[0]]; - if (argv.packetLoss) { + if (argv.localhost) { + options = { localhost: true, down: options.down, rtt: options.rtt }; + } else if (argv.packetLoss) { options.packetLoss = argv.packetLoss; } @@ -150,7 +152,11 @@ async function run(argv) { try { await start(options); if (options.localhost) { - console.log(`Started throttler on localhost RTT:${options.rtt}ms `); + const down = + options.down === undefined ? '' : ` Down:${options.down}kbit/s`; + console.log( + `Started throttler on localhost RTT:${options.rtt}ms${down}` + ); } else { let message = 'Started throttler:'; if (options.down !== undefined) { diff --git a/lib/index.js b/lib/index.js index 2884569..faa05c0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,6 +15,12 @@ function verify(options) { if (!Number.isInteger(options.rtt)) { throw new TypeError('You need to set rtt as an integer for localhost'); } + if (options.down !== undefined && !Number.isInteger(options.down)) { + throw new TypeError('down needs to be an integer'); + } + if (options.up !== undefined) { + throw new Error('Upload is not supported on localhost, use down'); + } } else if ( (options.up && !Number.isInteger(options.up)) || (options.down && !Number.isInteger(options.down)) || @@ -32,6 +38,9 @@ export async function start(options = {}) { switch (platform()) { case 'darwin': { if (options.localhost) { + if (options.down !== undefined) { + throw new Error('Download on localhost is only supported on Linux'); + } return startPfctlLocalhost(options.rtt); } return startPfctl( @@ -44,7 +53,7 @@ export async function start(options = {}) { case 'linux': { return options.localhost - ? startTcLocalhost(options.rtt) + ? startTcLocalhost(options.rtt, options.down) : startTc(options.up, options.down, options.rtt, options.packetLoss); } diff --git a/lib/localHostTc.js b/lib/localHostTc.js index d7f994a..3372ba2 100644 --- a/lib/localHostTc.js +++ b/lib/localHostTc.js @@ -6,8 +6,11 @@ import sudo from './sudo.js'; const NETWORK_MTU = '1500'; const LOOPBACK_DEFAULT_MTU = '65536'; -export async function start(delay) { +export async function start(delay, down) { const halfWayDelay = delay / 2; + // Both directions share loopback, so the rate is for all traffic. Requests + // and ACKs are small, so it works as a download limit. + const rate = down ? ['rate', `${down}kbit`] : []; try { await stop(); @@ -26,7 +29,8 @@ export async function start(delay) { '1:0', 'netem', 'delay', - `${halfWayDelay}ms` + `${halfWayDelay}ms`, + ...rate ); await sudo('ip', 'link', 'set', 'dev', 'lo', 'mtu', NETWORK_MTU); } diff --git a/test/start.sh b/test/start.sh index 968f66c..beb7d96 100755 --- a/test/start.sh +++ b/test/start.sh @@ -33,3 +33,15 @@ bin/index.js --rtt 200 --localhost ip link show lo | grep -q 'mtu 1500' bin/index.js --stop --localhost ip link show lo | grep -q 'mtu 65536' + +## Localhost with a download limit, from flags and from a profile +bin/index.js --rtt 200 --down 5000 --localhost +tc qdisc show dev lo | grep -q 'delay 100ms.*rate 5Mbit' +bin/index.js --stop --localhost +bin/index.js --profile cable --localhost +tc qdisc show dev lo | grep -q 'delay 7ms.*rate 5Mbit' +bin/index.js --stop --localhost +if tc qdisc show dev "$INTERFACE" | grep -q netem; then + echo "a profile with --localhost throttled $INTERFACE" >&2 + exit 1 +fi