From 80c0f72b54f5066063247d49dc3125c795022fd9 Mon Sep 17 00:00:00 2001 From: Harshit Date: Thu, 27 Aug 2026 12:54:33 +0530 Subject: [PATCH] test(media): add network-impairment harness (tc netem) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash + puppeteer harness that applies tc netem profiles (packet loss, jitter, bandwidth caps) to an interface and measures inbound-rtp getStats under each, to validate media resilience/degradation — currently zero coverage. - load-test/media-impairment.sh — root-gated netem driver; cleans up via trap - load-test/impairment-measure.js — puppeteer viewer + getStats sampler (JSON out) - load-test/results/MEDIA-NETWORK-IMPAIRMENT.md — profiles, safety, interpretation Note: simulcast/adaptStreamQuality is currently disabled server-side, so no layer adaptation is expected under impairment (documented as a finding + follow-up). Authored by Claude (Anthropic) via Claude Code. Not executed — results are placeholders, not fabricated. Co-Authored-By: Claude Opus 4.8 (1M context) --- load-test/impairment-measure.js | 347 ++++++++++++++++++ load-test/media-impairment.sh | 219 +++++++++++ load-test/results/MEDIA-NETWORK-IMPAIRMENT.md | 147 ++++++++ 3 files changed, 713 insertions(+) create mode 100644 load-test/impairment-measure.js create mode 100755 load-test/media-impairment.sh create mode 100644 load-test/results/MEDIA-NETWORK-IMPAIRMENT.md diff --git a/load-test/impairment-measure.js b/load-test/impairment-measure.js new file mode 100644 index 0000000..9b761bf --- /dev/null +++ b/load-test/impairment-measure.js @@ -0,0 +1,347 @@ +/* + * --------------------------------------------------------- + * CrowdStream - WebRTC media impairment measurer + * --------------------------------------------------------- + * + * Launches N puppeteer viewers into an EXISTING LIVE room, hooks + * RTCPeerConnection.getStats(), samples the inbound-rtp video stats + * every ~1s for --durationMs, and prints exactly ONE line of JSON to + * stdout: + * + * {"meanBitrateKbps":..,"lossPct":..,"jitterMs":..,"meanFps":..,"samples":..} + * + * Every diagnostic/progress message is written to stderr so the final + * (and only) stdout line stays pure JSON. media-impairment.sh applies + * tc/netem shaping around each run and captures that JSON line to build + * a per-profile comparison table. + * + * Style intentionally mirrors load-test/sfu-capacity.js (arg(), the + * login() flow, CHROME_FLAGS, percentile()). + * + * > Authored by Claude (Anthropic), via Claude Code - 2026-08-27. + * --------------------------------------------------------- + */ + +const puppeteer = require("puppeteer"); + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return def; + return process.argv[i + 1]; +} + +const BASE_URL = arg("baseUrl", "http://localhost"); +const ROOM_ID = arg("roomId", null); +const DURATION_MS = parseInt(arg("durationMs", "15000"), 10); +const VIEWERS = parseInt(arg("viewers", "1"), 10); + +// Credentials: explicit flags win, otherwise env. NEVER hardcoded. +const TEST_EMAIL = arg("email", process.env.CROWDSTREAM_TEST_EMAIL); +const TEST_PASSWORD = arg("password", process.env.CROWDSTREAM_TEST_PASSWORD); + +const WAIT_TIMEOUT_MS = 20000; + +if (!ROOM_ID) { + console.error("Missing --roomId "); + process.exit(1); +} + +if (!TEST_EMAIL || !TEST_PASSWORD) { + console.error( + "Missing credentials. Pass --email/--password or set " + + "CROWDSTREAM_TEST_EMAIL and CROWDSTREAM_TEST_PASSWORD." + ); + process.exit(1); +} + +const CHROME_FLAGS = [ + "--use-fake-device-for-media-stream", + "--use-fake-ui-for-media-stream", + "--disable-gpu", + "--no-sandbox", + "--mute-audio", +]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function mean(values) { + if (!values.length) return null; + const sum = values.reduce((a, b) => a + b, 0); + return sum / values.length; +} + +// Kept to mirror sfu-capacity.js; used only for a stderr diagnostic. +function percentile(values, p) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.min(Math.max(index, 0), sorted.length - 1)]; +} + +function round(x) { + if (x === null || x === undefined || Number.isNaN(x)) return null; + return Math.round(x * 100) / 100; +} + +async function login(page) { + console.error("Opening signin page..."); + + await page.goto(`${BASE_URL}/signin`, { + waitUntil: "networkidle2", + timeout: 30000, + }); + + await page.waitForSelector("#email", { timeout: WAIT_TIMEOUT_MS }); + + await page.type("#email", TEST_EMAIL); + await page.type("#password", TEST_PASSWORD); + + await page.click('button[type="submit"]'); + + // SignInPage navigates to /dashboard after a successful sign-in. + await page.waitForFunction( + () => window.location.pathname === "/dashboard", + { timeout: WAIT_TIMEOUT_MS } + ); + + await page.waitForFunction( + () => window.__csSocket && window.__csSocket.connected === true, + { timeout: WAIT_TIMEOUT_MS } + ); + + console.error("Login + socket connected."); +} + +/* + * Wrap window.RTCPeerConnection BEFORE any page script runs (via + * evaluateOnNewDocument) so every PeerConnection the viewer creates is + * captured into window.__csPCs. A Proxy preserves the prototype, static + * methods and instanceof semantics of the native class. + */ +async function installStatsHook(page) { + await page.evaluateOnNewDocument(() => { + window.__csPCs = []; + const Native = window.RTCPeerConnection; + if (!Native) return; + + window.RTCPeerConnection = new Proxy(Native, { + construct(target, args) { + const pc = new target(...args); + try { + window.__csPCs.push(pc); + } catch (e) { + /* ignore */ + } + return pc; + }, + }); + + if (window.webkitRTCPeerConnection) { + window.webkitRTCPeerConnection = window.RTCPeerConnection; + } + }); +} + +async function launchViewer(browser, index) { + const page = await browser.newPage(); + + page.on("pageerror", (error) => { + console.error(`[VIEWER ${index} PAGE ERROR] ${error.message}`); + }); + + // Hook must be installed before login() navigates. + await installStatsHook(page); + await login(page); + + console.error(`[VIEWER ${index}] joining room ${ROOM_ID}...`); + + await page.goto(`${BASE_URL}/viewer?roomId=${ROOM_ID}`, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + + await page.waitForSelector("form", { timeout: WAIT_TIMEOUT_MS }); + await page.$eval("form", (form) => form.requestSubmit()); + + try { + await page.waitForFunction(() => Boolean(window.__csJoinedAt), { + timeout: WAIT_TIMEOUT_MS, + }); + } catch (e) { + console.error(`[VIEWER ${index}] __csJoinedAt not detected.`); + } + + try { + await page.waitForFunction(() => Boolean(window.__csFirstFrameAt), { + timeout: WAIT_TIMEOUT_MS, + }); + console.error(`[VIEWER ${index}] first frame received.`); + } catch (e) { + console.error(`[VIEWER ${index}] __csFirstFrameAt not detected.`); + } + + return page; +} + +/* + * Snapshot inbound-rtp video stats across every viewer page's captured + * PeerConnections. getStats() runs in the browser context so it can + * reach the live PC objects; we return plain data to Node. + */ +async function sampleViewers(pages) { + const all = []; + + for (const page of pages) { + let reports; + try { + reports = await page.evaluate(async () => { + const out = []; + const pcs = window.__csPCs || []; + + for (const pc of pcs) { + let stats; + try { + stats = await pc.getStats(); + } catch (e) { + continue; + } + + stats.forEach((report) => { + const isVideo = + report.kind === "video" || report.mediaType === "video"; + + if (report.type === "inbound-rtp" && isVideo) { + out.push({ + id: report.id, + bytesReceived: report.bytesReceived || 0, + packetsReceived: report.packetsReceived || 0, + packetsLost: report.packetsLost || 0, + jitter: + typeof report.jitter === "number" ? report.jitter : null, + framesPerSecond: + typeof report.framesPerSecond === "number" + ? report.framesPerSecond + : null, + frameWidth: report.frameWidth || null, + frameHeight: report.frameHeight || null, + }); + } + }); + } + + return out; + }); + } catch (e) { + reports = []; + } + + all.push(...reports); + } + + return all; +} + +async function main() { + console.error("========================================"); + console.error("CrowdStream MEDIA IMPAIRMENT MEASURER"); + console.error("========================================"); + console.error(`Base URL: ${BASE_URL}`); + console.error(`Room ID: ${ROOM_ID}`); + console.error(`Viewers: ${VIEWERS}`); + console.error(`Duration: ${DURATION_MS}ms`); + + const browser = await puppeteer.launch({ + headless: true, + protocolTimeout: 120000, + args: CHROME_FLAGS, + }); + + try { + const context = browser.defaultBrowserContext(); + await context.overridePermissions(BASE_URL, ["camera", "microphone"]); + + const pages = []; + for (let i = 0; i < VIEWERS; i++) { + pages.push(await launchViewer(browser, i)); + } + + // Give mediasoup / the decoder a moment to stabilize before sampling. + await sleep(2000); + + const startedAt = Date.now(); + + let prevSumBytes = null; + let prevT = null; + + const bitrates = []; + const jitters = []; + const fpsVals = []; + const widths = []; + const heights = []; + + // RTP counters are cumulative; the last sample holds the run totals. + let lastTotals = { received: 0, lost: 0 }; + let samples = 0; + + while (Date.now() - startedAt < DURATION_MS) { + const reports = await sampleViewers(pages); + const now = Date.now(); + + const sumBytes = reports.reduce((a, r) => a + r.bytesReceived, 0); + const sumReceived = reports.reduce((a, r) => a + r.packetsReceived, 0); + const sumLost = reports.reduce((a, r) => a + r.packetsLost, 0); + + for (const r of reports) { + if (r.jitter !== null) jitters.push(r.jitter * 1000); // s -> ms + if (r.framesPerSecond !== null && r.framesPerSecond > 0) { + fpsVals.push(r.framesPerSecond); + } + if (r.frameWidth) widths.push(r.frameWidth); + if (r.frameHeight) heights.push(r.frameHeight); + } + + // Aggregate bitrate from the byte delta over wall-clock time. + if (prevSumBytes !== null && now > prevT) { + const dtSec = (now - prevT) / 1000; + const kbps = ((sumBytes - prevSumBytes) * 8) / dtSec / 1000; + if (kbps >= 0) bitrates.push(kbps); + } + + prevSumBytes = sumBytes; + prevT = now; + lastTotals = { received: sumReceived, lost: sumLost }; + samples++; + + await sleep(1000); + } + + const denom = lastTotals.received + lastTotals.lost; + const lossPct = denom > 0 ? (lastTotals.lost / denom) * 100 : null; + + // Diagnostics (stderr only) - the frame dimensions we sampled and a + // percentile view of bitrate, so the getStats fields are not wasted. + const maxW = widths.length ? Math.max(...widths) : null; + const maxH = heights.length ? Math.max(...heights) : null; + console.error(`resolution (diag, max): ${maxW}x${maxH}`); + console.error(`bitrate p95 (diag): ${round(percentile(bitrates, 95))} kbps`); + + const summary = { + meanBitrateKbps: round(mean(bitrates)), + lossPct: round(lossPct), + jitterMs: round(mean(jitters)), + meanFps: round(mean(fpsVals)), + samples, + }; + + // The ONLY stdout line - keep it pure JSON for the bash harness. + console.log(JSON.stringify(summary)); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error("\nMEASURE FAILED:"); + console.error(error); + process.exit(1); +}); diff --git a/load-test/media-impairment.sh b/load-test/media-impairment.sh new file mode 100755 index 0000000..d284b7f --- /dev/null +++ b/load-test/media-impairment.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# +# ============================================================================= +# CrowdStream - WebRTC media impairment load-test harness (tc/netem driver) +# ============================================================================= +# +# Applies a series of network-impairment profiles to a network interface with +# `tc netem`, runs impairment-measure.js (puppeteer viewers + getStats) under +# each profile, captures the single-line JSON summary, and prints a comparison +# table (profile | bitrateKbps | loss% | jitter | fps). +# +# !! DANGER - THIS SHAPES A REAL NETWORK INTERFACE !! +# ----------------------------------------------------------------------------- +# `tc qdisc ... netem` degrades ALL traffic on the chosen $IFACE (added delay, +# dropped packets, throttled bandwidth). Run this ONLY on a disposable test box +# or throwaway container. Prefer shaping the loopback / a dedicated test NIC +# (e.g. IFACE=lo) so you do not knock yourself off SSH or disrupt other work. +# NEVER run this against a production host or a shared interface. The script +# installs an EXIT/INT/TERM trap that always tears the qdisc back down, but a +# hard kill (kill -9) can still leave the interface shaped - if that happens, +# recover manually with: tc qdisc del dev root +# ----------------------------------------------------------------------------- +# +# > Authored by Claude (Anthropic), via Claude Code - 2026-08-27. +# ============================================================================= + +# Resolve where this script (and the measurer next to it) live. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MEASURE="$SCRIPT_DIR/impairment-measure.js" + +# ----------------------------------------------------------------------------- +# Config: flags override env; env overrides defaults. +# ----------------------------------------------------------------------------- +IFACE="${IFACE:-eth0}" +ROOM_ID="${ROOM_ID:-}" +BASE_URL="${BASE_URL:-http://localhost}" +DURATION_MS="${DURATION_MS:-15000}" +VIEWERS="${VIEWERS:-1}" +SETTLE_SECS="${SETTLE_SECS:-2}" + +# Credentials are read from the environment and passed through to the measurer. +CROWDSTREAM_TEST_EMAIL="${CROWDSTREAM_TEST_EMAIL:-}" +CROWDSTREAM_TEST_PASSWORD="${CROWDSTREAM_TEST_PASSWORD:-}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --iface) IFACE="$2"; shift 2 ;; + --roomId|--room) ROOM_ID="$2"; shift 2 ;; + --baseUrl) BASE_URL="$2"; shift 2 ;; + --durationMs) DURATION_MS="$2"; shift 2 ;; + --viewers) VIEWERS="$2"; shift 2 ;; + --email) CROWDSTREAM_TEST_EMAIL="$2"; shift 2 ;; + --password) CROWDSTREAM_TEST_PASSWORD="$2"; shift 2 ;; + -h|--help) + echo "Usage: sudo IFACE= ROOM_ID= BASE_URL= \\" + echo " CROWDSTREAM_TEST_EMAIL=... CROWDSTREAM_TEST_PASSWORD=... \\" + echo " $0 [--iface eth0] [--roomId ID] [--baseUrl URL] \\" + echo " [--durationMs 15000] [--viewers 1]" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# Export creds so the child `node` process inherits them (sudo may scrub env, +# so we re-export whatever we resolved from flags/env here). +export CROWDSTREAM_TEST_EMAIL +export CROWDSTREAM_TEST_PASSWORD + +# ----------------------------------------------------------------------------- +# Preconditions. +# ----------------------------------------------------------------------------- +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: this harness shapes a real NIC with tc/netem and must run as root." >&2 + echo "" >&2 + echo "Re-run with sudo, passing config + credentials through the environment:" >&2 + echo "" >&2 + echo " sudo IFACE=lo ROOM_ID= BASE_URL=http://localhost \\" >&2 + echo " CROWDSTREAM_TEST_EMAIL=you@example.com \\" >&2 + echo " CROWDSTREAM_TEST_PASSWORD=secret \\" >&2 + echo " $0" >&2 + exit 1 +fi + +if [ -z "$ROOM_ID" ]; then + echo "ERROR: ROOM_ID is required (an existing LIVE room). Set ROOM_ID=... or --roomId ID." >&2 + exit 1 +fi + +if [ ! -f "$MEASURE" ]; then + echo "ERROR: cannot find measurer at $MEASURE" >&2 + exit 1 +fi + +if ! command -v tc >/dev/null 2>&1; then + echo "ERROR: 'tc' not found. Install iproute2 (e.g. apt-get install iproute2)." >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: 'node' not found on PATH." >&2 + exit 1 +fi + +# ----------------------------------------------------------------------------- +# Cleanup: ALWAYS remove the netem qdisc, however we exit. +# ----------------------------------------------------------------------------- +cleanup() { + tc qdisc del dev "$IFACE" root 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +apply_profile() { + # $1 = netem args ("" => baseline / no shaping) + local netem="$1" + if [ -z "$netem" ]; then + # Baseline: ensure the interface is completely unshaped. + tc qdisc del dev "$IFACE" root 2>/dev/null || true + else + # `replace` is idempotent whether or not a root qdisc already exists. + # shellcheck disable=SC2086 # intentional word-splitting of netem args + tc qdisc replace dev "$IFACE" root netem $netem + fi +} + +# Pull a numeric (or null) field out of the measurer's JSON line. +jval() { + # $1 = json, $2 = key + echo "$1" | sed -n "s/.*\"$2\":\([^,}]*\).*/\1/p" | tr -d ' ' +} + +# ----------------------------------------------------------------------------- +# Profiles: "human label|netem args". Empty netem args == baseline. +# ----------------------------------------------------------------------------- +PROFILES=( + "baseline (none)|" + "loss 1%|loss 1%" + "loss 3%|loss 3%" + "loss 5%|loss 5%" + "jitter (delay 100ms 20ms normal)|delay 100ms 20ms distribution normal" + "rate 1mbit|rate 1mbit" +) + +echo "==============================================================" +echo "CrowdStream media impairment sweep" +echo " Interface : $IFACE (WARNING: real traffic on this NIC is shaped)" +echo " Room ID : $ROOM_ID" +echo " Base URL : $BASE_URL" +echo " Viewers : $VIEWERS" +echo " Duration : ${DURATION_MS}ms per profile" +echo "==============================================================" + +ROWS=() + +for entry in "${PROFILES[@]}"; do + name="${entry%%|*}" + netem="${entry#*|}" + + echo "" + echo ">>> Profile: $name" + if [ -z "$netem" ]; then + echo " netem: (none / baseline)" + else + echo " netem: $netem" + fi + + apply_profile "$netem" + + # Let the shaping take effect before measuring. + sleep "$SETTLE_SECS" + + # Run the measurer. Diagnostics -> stderr (dropped here); the pure JSON + # summary is the only stdout line, so tail -n 1 grabs it reliably. + json="$(node "$MEASURE" \ + --baseUrl "$BASE_URL" \ + --roomId "$ROOM_ID" \ + --durationMs "$DURATION_MS" \ + --viewers "$VIEWERS" 2>/dev/null | tail -n 1)" + + if [ -z "$json" ]; then + echo " result: (no JSON captured - measurer failed; see run without 2>/dev/null)" + bitrate="ERR"; loss="ERR"; jitter="ERR"; fps="ERR" + else + echo " result: $json" + bitrate="$(jval "$json" meanBitrateKbps)" + loss="$(jval "$json" lossPct)" + jitter="$(jval "$json" jitterMs)" + fps="$(jval "$json" meanFps)" + [ -z "$bitrate" ] && bitrate="n/a" + [ -z "$loss" ] && loss="n/a" + [ -z "$jitter" ] && jitter="n/a" + [ -z "$fps" ] && fps="n/a" + fi + + ROWS+=("$(printf '%-38s %14s %10s %10s %8s' "$name" "$bitrate" "$loss" "$jitter" "$fps")") + + # Reset to unshaped between profiles. + tc qdisc del dev "$IFACE" root 2>/dev/null || true +done + +# ----------------------------------------------------------------------------- +# Comparison table. +# ----------------------------------------------------------------------------- +echo "" +echo "==============================================================" +echo "RESULTS (compare each profile vs. baseline)" +echo "==============================================================" +printf '%-38s %14s %10s %10s %8s\n' "profile" "bitrateKbps" "loss%" "jitterMs" "fps" +printf '%-38s %14s %10s %10s %8s\n' "--------------------------------------" "--------------" "----------" "----------" "--------" +for row in "${ROWS[@]}"; do + echo "$row" +done +echo "==============================================================" +echo "Note: simulcast/layer switching is DISABLED in the backend" +echo "(adaptStreamQuality.ts is fully commented out), so expect flat" +echo "quality degradation - no adaptive layer downgrade under loss." diff --git a/load-test/results/MEDIA-NETWORK-IMPAIRMENT.md b/load-test/results/MEDIA-NETWORK-IMPAIRMENT.md new file mode 100644 index 0000000..43cef3a --- /dev/null +++ b/load-test/results/MEDIA-NETWORK-IMPAIRMENT.md @@ -0,0 +1,147 @@ +# Media Network Impairment Load Test + +> Authored by Claude (Anthropic), via Claude Code — 2026-08-27. +> (This harness — both scripts and this document — was written by Claude.) + +## Why this exists (roadmap gap) + +CrowdStream's existing load tests (`sfu-capacity.js`, `signaling-latency.js`) +measure how many viewers can join and how fast the first frame arrives on a +**healthy** network. They say nothing about what happens when the network is +**bad**. Media resilience under packet loss, jitter, and constrained bandwidth +currently has **zero coverage** — yet that is exactly the condition real viewers +hit on mobile and congested Wi‑Fi. + +This harness fills that gap: it deterministically degrades the network with +`tc netem`, then measures the resulting WebRTC receive quality (bitrate, packet +loss, jitter, frame rate) from real Chromium viewers. + +## What it tests + +For each network profile, it: + +1. Launches N puppeteer viewers into an **existing LIVE room** (log in → + `/viewer?roomId=…` → submit the join form → wait for `__csJoinedAt` and + `__csFirstFrameAt`). +2. Hooks `RTCPeerConnection` (via `evaluateOnNewDocument`, before page scripts + run) so every PeerConnection is captured into `window.__csPCs`. +3. Samples `getStats()` `inbound-rtp` **video** reports once per second for the + run duration, deriving: + - **meanBitrateKbps** — from the `bytesReceived` delta over wall-clock time. + - **lossPct** — `packetsLost / (packetsLost + packetsReceived) * 100`. + - **jitterMs** — mean of the `jitter` field (seconds → ms). + - **meanFps** — mean of `framesPerSecond`. + - (frame width/height sampled too, reported as a stderr diagnostic.) +4. Emits a single-line JSON summary on stdout that the bash harness captures. + +## The netem profiles + +| Profile | `tc netem` args | Simulates | +|---|---|---| +| baseline | *(qdisc removed — no shaping)* | Healthy control run | +| loss 1% | `loss 1%` | Mild packet loss | +| loss 3% | `loss 3%` | Moderate packet loss | +| loss 5% | `loss 5%` | Heavy packet loss | +| jitter | `delay 100ms 20ms distribution normal` | 100 ms RTT ± 20 ms normal jitter | +| rate 1mbit | `rate 1mbit` | Bandwidth-constrained link | + +Each profile is applied with `tc qdisc replace dev $IFACE root netem …` +(baseline uses `tc qdisc del dev $IFACE root`), followed by a short settle +delay before the measurement run. + +## ROOT + safety warning + +**This harness shapes a REAL network interface.** `tc netem` degrades *all* +traffic on the chosen `$IFACE` — every socket on that NIC gets the added delay, +dropped packets, or throttled bandwidth. + +- The script **refuses to run unless it is root** (it needs `CAP_NET_ADMIN`). +- Run it **only on a disposable test box or throwaway container**. +- Prefer shaping the **loopback / a dedicated test NIC** (e.g. `IFACE=lo`) so + you don't cut your own SSH session or disrupt unrelated work. +- **Never** run it against a production host or a shared interface. +- An `EXIT`/`INT`/`TERM` trap always tears the qdisc down. A hard `kill -9` can + still leave the interface shaped — recover manually with: + `tc qdisc del dev root`. + +## Prerequisites + +- Node with `puppeteer` resolvable from where `impairment-measure.js` runs. + The repo's copy lives in `load-test/node_modules`, so run from there or set + `NODE_PATH=/home/harshit/CrowdStream/load-test/node_modules` (these harness + files live in `/tmp` for review and are not wired into that `node_modules`). +- `iproute2` (`tc`) installed. +- An **existing LIVE room** (start a broadcaster first, e.g. via + `sfu-capacity.js`, and note the room id). +- Test credentials in the environment: + `CROWDSTREAM_TEST_EMAIL`, `CROWDSTREAM_TEST_PASSWORD`. + +## How to run + +```bash +# From a DISPOSABLE test box, as root. Shape loopback to stay safe. +sudo IFACE=lo ROOM_ID= BASE_URL=http://localhost \ + CROWDSTREAM_TEST_EMAIL=you@example.com \ + CROWDSTREAM_TEST_PASSWORD=secret \ + ./media-impairment.sh +``` + +Optional knobs (flags or env): `--viewers N` (`VIEWERS`), +`--durationMs MS` (`DURATION_MS`), `--iface NIC` (`IFACE`), +`--baseUrl URL` (`BASE_URL`). + +You can also run the measurer standalone (no shaping) to sanity-check it: + +```bash +CROWDSTREAM_TEST_EMAIL=… CROWDSTREAM_TEST_PASSWORD=… \ + node impairment-measure.js --roomId --durationMs 15000 --viewers 1 +# -> {"meanBitrateKbps":..,"lossPct":..,"jitterMs":..,"meanFps":..,"samples":..} +``` + +## How to interpret the results + +- **Always compare each profile against the baseline row**, not against + absolute expectations — the fake media source and box capacity set the + ceiling. What matters is the *delta*: how far bitrate/fps fall and how far + loss/jitter climb as the network worsens. +- Rising `lossPct` and `jitterMs` with falling `meanFps` under the loss/jitter + profiles is the expected signature of a stream with no recovery mechanism. +- **Simulcast / adaptive layer switching is currently OFF.** The backend's + `backend/src/utils/adaptStreamQuality.ts` is **entirely commented out** + (including the `consumer.setPreferredLayers({ spatialLayer: 0 })` downgrade + path). So do **not** expect the SFU to drop viewers to a lower spatial layer + under impairment — you should see **flat quality degradation**, not a clean + step-down to a lower resolution. That flat degradation *is* the finding. + **Recommended follow-up:** re-enable `adaptStreamQuality.ts` (and the + encoder-side simulcast layers) and re-run this sweep to confirm adaptive + downgrade actually kicks in — the baseline captured here becomes the + before/after reference. + +## Tooling alternatives + +`tc netem` is the lightest-weight option but shapes a whole interface and needs +root. Alternatives worth considering for CI or safer isolation: + +- **[toxiproxy](https://github.com/Shopify/toxiproxy)** — a TCP proxy with a + control API; add latency/bandwidth/loss "toxics" per proxied connection + without touching kernel qdiscs. Great for scripted, per-connection faults + (note: proxies the signaling/TCP path; raw UDP media needs a UDP-capable + path). +- **[comcast](https://github.com/tylertreat/comcast)** — a friendly wrapper + over `tc`/`ipfw` with simple `--latency/--packet-loss/--target-bw` flags; + still shapes a real interface, so the same root/safety caveats apply. +- Per-container **network namespaces** (`ip netns`) or Docker `--network` + isolation to keep shaping off the host NIC entirely. + +## Results + +_Pending execution — not fabricated._ + +| profile | bitrateKbps | loss% | jitterMs | fps | +|---|---|---|---|---| +| baseline (none) | _pending_ | _pending_ | _pending_ | _pending_ | +| loss 1% | _pending_ | _pending_ | _pending_ | _pending_ | +| loss 3% | _pending_ | _pending_ | _pending_ | _pending_ | +| loss 5% | _pending_ | _pending_ | _pending_ | _pending_ | +| jitter (delay 100ms 20ms normal) | _pending_ | _pending_ | _pending_ | _pending_ | +| rate 1mbit | _pending_ | _pending_ | _pending_ | _pending_ |