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
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
registry.npmjs.org:443
security.ubuntu.com:80
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG - throttle

# UNRELEASED
### Fixed
* Linux throttling no longer fails with "Exclusivity flag on, cannot modify" when another tool already holds the ingress qdisc slot on the interface. GitHub-hosted Actions runners now come with an eBPF network-monitoring agent that attaches a clsact qdisc to the default interface (other eBPF-based security agents do the same), and since only one ingress/clsact qdisc can exist per device, throttle's `tc qdisc add ... ingress` failed and took the whole measurement run down with it. The original code path is unchanged and runs first; only when it fails does throttle fall back to attaching its redirect filters to the existing qdisc (on the clsact ingress hook when needed), with explicit filter priorities so stop removes exactly its own filters and leaves the other tool's qdisc and filters untouched.

# 6.0.0 - 2026-03-23
### Added
* Added IPv6 throttling support on macOS [#94](https://github.com/sitespeedio/throttle/pull/94) and Linux [#100](https://github.com/sitespeedio/throttle/pull/100).
Expand Down
107 changes: 106 additions & 1 deletion lib/tc.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,101 @@ async function setup(defaultInterface) {
);
}

// Only used when setup() fails because another tool already holds the
// ingress qdisc slot. Explicit filter priorities make it possible to
// remove exactly these filters on stop without touching filters added
// by whoever owns the qdisc. High numbers run last, after auto-assigned
// priorities (49152 and down), so monitoring agents see the traffic
// before it is redirected to ifb0.
const FILTER_PREF_IP = '65000';
const FILTER_PREF_IPV6 = '65001';

async function getIngressSlotKind(defaultInterface) {
const result = await shell(`sudo tc qdisc show dev ${defaultInterface}`);
const match = result.stdout.match(/^qdisc (ingress|clsact) ffff:/m);
return match ? match[1] : undefined;
}

async function setupOnBusyIngressSlot(defaultInterface, setupError) {
// The ingress slot is exclusive per device and can already be held by
// another tool: GitHub-hosted runners come with an eBPF monitoring
// agent that attaches a clsact qdisc to the default interface, which
// makes the plain qdisc add in setup() fail with "Exclusivity flag on,
// cannot modify". Filters can attach to the existing hook instead, but
// clsact only accepts them on its ffff:fff2 ingress class.
const kind = await getIngressSlotKind(defaultInterface);
if (kind !== 'ingress' && kind !== 'clsact') {
throw setupError;
}
const parent = kind === 'clsact' ? 'ffff:fff2' : 'ffff:';

for (const [protocol, pref] of [
['ip', FILTER_PREF_IP],
['ipv6', FILTER_PREF_IPV6]
]) {
await sudo(
'tc',
'filter',
'add',
'dev',
defaultInterface,
'parent',
parent,
'protocol',
protocol,
'pref',
pref,
'u32',
'match',
'u32',
'0',
'0',
'flowid',
'1:1',
'action',
'mirred',
'egress',
'redirect',
'dev',
'ifb0'
);
}
}

async function removeBusyIngressSlotFilters(indexFace) {
// Only relevant when setupOnBusyIngressSlot() attached the filters to a
// qdisc that belongs to another tool: the qdisc deletes in stop() leave
// that qdisc alone (they fail on kind mismatch), so remove exactly our
// filters and nothing else.
const kind = await getIngressSlotKind(indexFace);
if (!kind) {
return;
}
const parent = kind === 'clsact' ? 'ffff:fff2' : 'ffff:';
for (const [protocol, pref] of [
['ip', FILTER_PREF_IP],
['ipv6', FILTER_PREF_IPV6]
]) {
try {
await sudo(
'tc',
'filter',
'del',
'dev',
indexFace,
'parent',
parent,
'protocol',
protocol,
'pref',
pref
);
} catch {
// ignore
}
}
}

async function setLimits(up, down, halfWayRTT, packetLoss, indexFace) {
if (down) {
const parameters = [
Expand Down Expand Up @@ -184,7 +279,11 @@ export async function start(up, down, rtt = 0, packetLoss = 0) {
const indexFace = await getDefaultInterface();
await moduleProbe();
await setupifb0();
await setup(indexFace);
try {
await setup(indexFace);
} catch (error) {
await setupOnBusyIngressSlot(indexFace, error);
}
await setLimits(up, down, halfWayRTT, packetLoss, indexFace);
}
export async function stop() {
Expand All @@ -202,6 +301,12 @@ export async function stop() {
// ignore
}

try {
await removeBusyIngressSlotFilters(indexFace);
} catch {
// ignore
}

try {
await sudo('tc', 'qdisc', 'del', 'dev', 'ifb0', 'root');
} catch {
Expand Down
2 changes: 1 addition & 1 deletion test/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM sitespeedio/node:ubuntu-20.04-nodejs-16.5.0
FROM sitespeedio/node:ubuntu-24-04-nodejs-24.18.0

RUN apt-get update && apt-get install libnss3-tools iproute2 sudo net-tools -y
RUN mkdir -p /usr/src/app
Expand Down
21 changes: 21 additions & 0 deletions test/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,25 @@ set -e
bin/index.js --profile 3gslow
bin/index.js --stop
bin/index.js --profile cable
bin/index.js --stop

## The ingress slot can already be taken by another tool, for example the
## eBPF monitoring agent on hardened GitHub Actions runners (clsact).
## Throttle should attach its filters to the existing qdisc and leave the
## qdisc alone on stop.
INTERFACE=$(ip route | awk '/default/ {print $5; exit}')
tc qdisc add dev "$INTERFACE" clsact
bin/index.js --profile cable
tc filter show dev "$INTERFACE" ingress | grep -q mirred
bin/index.js --stop
tc qdisc show dev "$INTERFACE" | grep -q clsact
if tc filter show dev "$INTERFACE" ingress | grep -q mirred; then
echo "throttle filters were not cleaned up" >&2
exit 1
fi
tc qdisc del dev "$INTERFACE" clsact

## Same thing when a plain ingress qdisc already exists
tc qdisc add dev "$INTERFACE" ingress
bin/index.js --profile cable
bin/index.js --stop
Loading