From 6190ab7b1e56ec9b971a1af4a0adbae18c7a3517 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Wed, 15 Jul 2026 20:49:06 +0200 Subject: [PATCH 1/3] Fall back to sharing the ingress qdisc slot when it is taken GitHub-hosted Actions runners now ship an eBPF network-monitoring agent that holds a clsact qdisc on the default interface, and the kernel allows only one ingress/clsact qdisc per device, so throttle's "tc qdisc add ... ingress" fails with "Exclusivity flag on, cannot modify" and takes the whole run down. The original setup and stop paths are unchanged and always run first; only when the qdisc add fails does throttle attach its redirect filters to the existing qdisc instead, with explicit filter priorities so stop can remove exactly its own filters and leave the other tool's qdisc and filters alone. Co-authored-by: Claude Fable 5 noreply@anthropic.com --- CHANGELOG.md | 4 ++ lib/tc.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++- test/start.sh | 21 ++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af637e0..b65ce71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/lib/tc.js b/lib/tc.js index 31750c2..ae192b9 100644 --- a/lib/tc.js +++ b/lib/tc.js @@ -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 = [ @@ -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() { @@ -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 { diff --git a/test/start.sh b/test/start.sh index dc72101..a8c159e 100755 --- a/test/start.sh +++ b/test/start.sh @@ -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 \ No newline at end of file From 22c8a3b7d6489616869a372359e2f195c2e5b6a9 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Wed, 15 Jul 2026 20:54:32 +0200 Subject: [PATCH 2/3] bump --- test/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Dockerfile b/test/Dockerfile index 3e8a986..2c39843 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -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 From b599b7660a4a8daaed24602be17ddda83ad31f48 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Thu, 16 Jul 2026 08:25:31 +0200 Subject: [PATCH 3/3] hepp --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5d65fea..2ecc5b3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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