diff --git a/app/cypress/e2e/calculators/speedtest.cy.js b/app/cypress/e2e/calculators/speedtest.cy.js new file mode 100644 index 00000000..fce3af0d --- /dev/null +++ b/app/cypress/e2e/calculators/speedtest.cy.js @@ -0,0 +1,20 @@ +describe('Speed Test calculator', () => { + beforeEach(() => { + // The byte streams themselves are not worth driving from Cypress; this is a render + // smoke test. Mock the endpoints the page calls on load so it renders without an API. + cy.intercept('GET', '/api/status', {statusCode: 200, body: {version: '1.0.0', flags: {}}}).as('getStatus'); + cy.intercept('GET', '/api/settings', {statusCode: 200, body: {settings: {}}}).as('getSettings'); + cy.intercept('GET', '/api/tag*', {statusCode: 200, body: {tags: []}}); + cy.intercept('GET', '/api/events/feed*', {statusCode: 200, body: {events: []}}); + }); + + it('is listed under Tools & Reference and renders idle', () => { + cy.visit('/more/calculators'); + cy.contains('h3', 'Tools & Reference').should('be.visible'); + cy.contains('a', 'Speed Test').click(); + cy.url().should('include', 'calc=speedtest'); + cy.contains('button', 'Start test').should('be.visible'); + cy.get('[data-testid="speedtest-phase"]').should('contain', 'Ready'); + cy.get('[data-testid="speedtest-results"]').should('not.exist'); + }); +}); diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js index 5c04945f..99878cde 100644 --- a/app/src/components/Calculators.js +++ b/app/src/components/Calculators.js @@ -13,6 +13,7 @@ import {WaterCalculator} from "./calculators/WaterCalculator"; import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator"; import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator"; import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator"; +import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator"; // Calculators are shown in these titled groups, in this order. Each calculator carries a // `group` matching one of these names; any calculator whose group is missing/unknown falls @@ -41,6 +42,7 @@ export const useCalculators = () => { {key: 'qrCode', icon: 'qrcode', button: 'QR Code', group: 'Tools & Reference', contents: }, {key: 'otp', icon: 'lock', button: 'One Time Pad', title: 'One Time Pad', group: 'Radio & Communications', contents: }, {key: 'vin', icon: 'car', button: 'VIN Decoder', title: 'VIN Decoder', group: 'Tools & Reference', contents: }, + {key: 'speedtest', icon: 'tachometer alternate', button: 'Speed Test', title: 'Speed Test', group: 'Tools & Reference', contents: }, ]; const activeCalculator = calc ? calculators.find(i => i.key === calc) : null; diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 377f2925..7767a317 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -25,6 +25,7 @@ import { PathInput, Placeholder, Progress, + Sparkline, SearchBox, Select, Statistic, @@ -567,6 +568,19 @@ export function ThemeSamplePage() { +
+ + {/* A null sample must show as a gap, not a drop to zero (a lost ping). */} +
+ + + +
+
+
+
{/* diff --git a/app/src/components/calculators/SpeedTestCalculator.js b/app/src/components/calculators/SpeedTestCalculator.js new file mode 100644 index 00000000..3ebeb53b --- /dev/null +++ b/app/src/components/calculators/SpeedTestCalculator.js @@ -0,0 +1,285 @@ +import React, {useContext, useEffect, useRef, useState} from "react"; +import {StatusContext} from "../../contexts/contexts"; +import {Button, Grid, Header, Label, Message, Panel, Progress, Sparkline, Statistic, StatisticGroup, Table, Text} from "../ui"; +import { + bytesToMbps, + describePing, + fetchInfo, + formatMbps, + formatMs, + isHotspotAddress, + mbpsToMBps, + otherActiveTests, + PHASE_SECONDS, + PING_SAMPLES, + runDownload, + runPing, + runUpload, + streamCapacity, +} from "./speedtest"; + +const PHASES = { + idle: {label: 'Ready', order: 0}, + starting: {label: 'Connecting…', order: 1}, + ping: {label: 'Measuring latency…', order: 1}, + download: {label: 'Downloading…', order: 2}, + upload: {label: 'Uploading…', order: 3}, + done: {label: 'Complete', order: 4}, + cancelled: {label: 'Cancelled', order: 4}, + error: {label: 'Failed', order: 4}, +}; + +// The rolling trace shows this many buckets (PHASE_SECONDS at 250 ms each). +const TRACE_LENGTH = PHASE_SECONDS * 4; + +const emptyResults = () => ({ping: null, download: null, upload: null}); + +/** + * Measures the network between this browser and the WROLPi: ping, then a 10 s download, then a + * 10 s upload, all against the API. Nothing is stored. + */ +export function SpeedTestCalculator() { + const {status} = useContext(StatusContext) || {}; + const hotspotConnected = status?.hotspot_status === 'connected'; + + const [phase, setPhase] = useState('idle'); + const [results, setResults] = useState(emptyResults); + const [live, setLive] = useState({mbps: 0, trace: [], progress: 0, rtts: []}); + const [info, setInfo] = useState(null); + const [activeDuring, setActiveDuring] = useState(0); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + + // Abort a running test if the user navigates away. + useEffect(() => () => controllerRef.current?.abort(), []); + + const onThroughput = buckets => { + const trace = buckets.samples.slice(-TRACE_LENGTH).map(s => bytesToMbps(s.bytes, s.width)); + setLive({ + mbps: buckets.currentMbps, + trace, + progress: Math.min(100, (buckets.elapsedMs / (PHASE_SECONDS * 1000)) * 100), + rtts: [], + }); + }; + + const sampleActive = async () => { + try { + const during = await fetchInfo(); + setActiveDuring(prev => Math.max(prev, during.active_tests)); + } catch (e) { + // Context only; the test result does not depend on it. + } + }; + + const start = async () => { + if (controllerRef.current) { + // A run is already in progress (its first await has not resolved yet). + return; + } + const controller = new AbortController(); + controllerRef.current = controller; + const {signal} = controller; + // Leave `idle` synchronously so the button reads Cancel before the first await. + setPhase('starting'); + setResults(emptyResults()); + setError(null); + setActiveDuring(0); + setLive({mbps: 0, trace: [], progress: 0, rtts: []}); + + try { + try { + setInfo(await fetchInfo()); + } catch (e) { + setInfo(null); + throw new Error('The WROLPi API could not be reached.'); + } + + setPhase('ping'); + const ping = await runPing({ + signal, + onSample: rtts => setLive({mbps: 0, trace: [], progress: (rtts.length / PING_SAMPLES) * 100, rtts}), + }); + if (signal.aborted) return; + setResults(r => ({...r, ping})); + + setPhase('download'); + setLive({mbps: 0, trace: [], progress: 0, rtts: []}); + // Sample the counter while our own stream is open so concurrent testers show up. + const midDownload = setTimeout(sampleActive, (PHASE_SECONDS * 1000) / 2); + let download; + try { + download = await runDownload({signal, onProgress: onThroughput}); + } finally { + clearTimeout(midDownload); + } + if (signal.aborted) return; + setResults(r => ({...r, download})); + + setPhase('upload'); + setLive({mbps: 0, trace: [], progress: 0, rtts: []}); + const midUpload = setTimeout(sampleActive, (PHASE_SECONDS * 1000) / 2); + let upload; + try { + upload = await runUpload({signal, onProgress: onThroughput}); + } finally { + clearTimeout(midUpload); + } + if (signal.aborted) return; + setResults(r => ({...r, upload})); + + setPhase('done'); + } catch (e) { + if (signal.aborted) { + return; + } + console.error(e); + setError(e.message || 'The speed test failed.'); + setPhase('error'); + } finally { + if (signal.aborted) { + setPhase('cancelled'); + } + controllerRef.current = null; + } + }; + + const cancel = () => controllerRef.current?.abort(); + + const running = ['starting', 'ping', 'download', 'upload'].includes(phase); + const finished = ['done', 'cancelled', 'error'].includes(phase); + const others = otherActiveTests(info?.active_tests, activeDuring); + + const liveRtt = live.rtts.length ? live.rtts[live.rtts.length - 1] : null; + const liveTrace = phase === 'ping' ? live.rtts : live.trace; + const liveValue = phase === 'ping' + ? (liveRtt === null ? '—' : formatMs(liveRtt)) + : formatMbps(live.mbps); + const liveUnit = phase === 'ping' ? 'ms' : 'Mbps'; + + return
+ + + + {running + ? + : } + + + {PHASES[phase].label} + {running && } + + + + {running && phase !== 'starting' &&
+
+ + {liveValue} + + {liveUnit} +
+ +
} + + {error && {error}} +
+ + {(results.ping || results.download || results.upload) && <> +
Results
+ + {results.download && } + {results.upload && } + {results.ping && } + {results.ping && } + + + + + {results.download && + Download + {formatMbps(results.download.mbps)} Mbps + {formatMbps(mbpsToMBps(results.download.mbps))} MB/s + peak {formatMbps(results.download.peak)} Mbps + } + {results.upload && + Upload + {formatMbps(results.upload.mbps)} Mbps + {formatMbps(mbpsToMBps(results.upload.mbps))} MB/s + peak {formatMbps(results.upload.peak)} Mbps + } + {results.ping && + Ping + {formatMs(results.ping.median)} ms median + {formatMs(results.ping.min)} ms best + + + {results.ping.lost > 0 && ` ${results.ping.lost} of ${PING_SAMPLES} lost`} + + } + +
+ + + + {results.download && } + } + + {phase === 'idle' && + Runs {PING_SAMPLES} pings, then a {PHASE_SECONDS} second download and a {PHASE_SECONDS} second + upload between this device and the WROLPi. Nothing is saved. + } +
+} + +function PingVerdict({median}) { + const {kind, text} = describePing(median); + const color = {success: 'green', warning: 'yellow', danger: 'red'}[kind] || 'grey'; + return ; +} + +export function ConnectionContext({info, hotspotConnected, others}) { + if (!info) { + return null; + } + const viaHotspot = hotspotConnected && isHotspotAddress(info.client_ip); + return + Tested from {info.client_ip}{' '} + {viaHotspot + ? + : } + {others > 0 && {others === 1 ? '1 other speed test was' : `${others} other speed tests were`} running + at the same time, so the link was shared.} + ; +} + +export function StreamCapacity({mbps}) { + const rows = streamCapacity(mbps); + return
+
Simultaneous video streams
+ + + + Quality + About + Streams at once + + + + {rows.map(row => + {row.label} + {row.mbps} Mbps + {row.streams} + )} + +
+ + Measures the network between this device and the WROLPi. Videos are served from the drive, which + can also limit how many play at once. Bitrates are typical for each resolution; individual videos + vary. + +
; +} diff --git a/app/src/components/calculators/SpeedTestCalculator.test.js b/app/src/components/calculators/SpeedTestCalculator.test.js new file mode 100644 index 00000000..a7f1a075 --- /dev/null +++ b/app/src/components/calculators/SpeedTestCalculator.test.js @@ -0,0 +1,158 @@ +import React from "react"; +import {act, fireEvent, screen, waitFor} from "@testing-library/react"; +import {renderWithProviders} from "../../test-utils"; +import {ConnectionContext, SpeedTestCalculator, StreamCapacity} from "./SpeedTestCalculator"; +import * as speedtest from "./speedtest"; + +jest.mock('./speedtest', () => { + const actual = jest.requireActual('./speedtest'); + return { + ...actual, + fetchInfo: jest.fn(), + runPing: jest.fn(), + runDownload: jest.fn(), + runUpload: jest.fn(), + }; +}); + +const deferred = () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + return {promise, resolve}; +}; + +beforeEach(() => { + jest.clearAllMocks(); + speedtest.fetchInfo.mockResolvedValue({client_ip: '10.0.0.50', active_tests: 0}); + speedtest.runPing.mockResolvedValue({min: 4, median: 6, jitter: 1.5, lost: 0}); + speedtest.runDownload.mockResolvedValue({mbps: 87.5, peak: 95, bytes: 1e8, samples: []}); + speedtest.runUpload.mockResolvedValue({mbps: 21.2, peak: 25, bytes: 3e7, samples: []}); +}); + +describe('SpeedTestCalculator', () => { + test('idle state shows Start and no results', () => { + renderWithProviders(); + expect(screen.getByRole('button', {name: /start test/i})).toBeInTheDocument(); + expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Ready'); + expect(screen.queryByTestId('speedtest-results')).not.toBeInTheDocument(); + }); + + test('runs ping, download, upload in order and shows results and stream capacity', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: /start test/i})); + + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Complete')); + + expect(speedtest.runPing).toHaveBeenCalledTimes(1); + expect(speedtest.runDownload).toHaveBeenCalledTimes(1); + expect(speedtest.runUpload).toHaveBeenCalledTimes(1); + // Order: ping before download before upload. + expect(speedtest.runPing.mock.invocationCallOrder[0]) + .toBeLessThan(speedtest.runDownload.mock.invocationCallOrder[0]); + expect(speedtest.runDownload.mock.invocationCallOrder[0]) + .toBeLessThan(speedtest.runUpload.mock.invocationCallOrder[0]); + + const results = screen.getByTestId('speedtest-results'); + expect(results).toHaveTextContent('87.5 Mbps'); + expect(results).toHaveTextContent('21.2 Mbps'); + expect(results).toHaveTextContent('6.0 ms'); + + // 87.5 Mbps: 35 SD, 17 HD, 10 Full HD, 5 2K, 3 4K streams. + const capacity = screen.getByTestId('speedtest-capacity'); + const cells = Array.from(capacity.querySelectorAll('tbody td:last-child')).map(td => td.textContent); + expect(cells).toEqual(['35', '17', '10', '5', '3']); + + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('10.0.0.50'); + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('via LAN'); + expect(screen.getByRole('button', {name: /run again/i})).toBeInTheDocument(); + }); + + test('Start becomes Cancel before the first await resolves, so a second run cannot be started', async () => { + // Hold the first await so Start is clicked again before the ping phase begins. + const pending = deferred(); + speedtest.fetchInfo.mockImplementation(() => pending.promise); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: /start test/i})); + // The button has already become Cancel, and a stray click on it is the abort path, + // so hunt for any Start/Run again button and click it if one exists. + expect(screen.queryByRole('button', {name: /start test|run again/i})).toBeNull(); + expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Connecting'); + + pending.resolve({client_ip: '10.0.0.50', active_tests: 0}); + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Complete')); + expect(speedtest.fetchInfo).toHaveBeenCalledTimes(1); + expect(speedtest.runPing).toHaveBeenCalledTimes(1); + }); + + test('Cancel aborts the run and shows no results for unfinished phases', async () => { + const pending = deferred(); + speedtest.runDownload.mockImplementation(({signal}) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + pending.promise.then(resolve); + })); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: /start test/i})); + + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Downloading')); + const {signal} = speedtest.runDownload.mock.calls[0][0]; + fireEvent.click(screen.getByRole('button', {name: /cancel/i})); + + expect(signal.aborted).toBe(true); + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Cancelled')); + expect(speedtest.runUpload).not.toHaveBeenCalled(); + // Ping finished before the cancel, so it is shown; download is not. + expect(screen.getByTestId('speedtest-results')).toHaveTextContent('Ping'); + expect(screen.getByTestId('speedtest-results')).not.toHaveTextContent('Download'); + }); + + test('an unreachable API is reported', async () => { + speedtest.fetchInfo.mockRejectedValue(new Error('HTTP 502')); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: /start test/i})); + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Failed')); + expect(screen.getByText(/could not be reached/i)).toBeInTheDocument(); + expect(speedtest.runPing).not.toHaveBeenCalled(); + }); + + test('a failing download phase is reported', async () => { + speedtest.runDownload.mockRejectedValue(new Error('Download test failed: HTTP 500')); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: /start test/i})); + await waitFor(() => expect(screen.getByTestId('speedtest-phase')).toHaveTextContent('Failed')); + expect(screen.getByText(/HTTP 500/)).toBeInTheDocument(); + }); +}); + +describe('ConnectionContext', () => { + test('hotspot badge needs both a hotspot address and the hotspot to be up', () => { + const {rerender} = renderWithProviders( + ); + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('via WROLPi hotspot'); + + rerender(); + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('via LAN'); + + rerender(); + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('via LAN'); + }); + + test('mentions concurrent tests', () => { + renderWithProviders(); + expect(screen.getByTestId('speedtest-context')).toHaveTextContent('2 other speed tests were running'); + }); + + test('renders nothing without info', () => { + const {container} = renderWithProviders(); + expect(container.querySelector('[data-testid="speedtest-context"]')).toBeNull(); + }); +}); + +describe('StreamCapacity', () => { + test('shows every tier with the disk caveat', () => { + renderWithProviders(); + expect(screen.getByText('1080p (Full HD)')).toBeInTheDocument(); + expect(screen.getByText(/served from the drive/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/calculators/speedtest.js b/app/src/components/calculators/speedtest.js new file mode 100644 index 00000000..a6d2f92a --- /dev/null +++ b/app/src/components/calculators/speedtest.js @@ -0,0 +1,339 @@ +/** + * Speed test logic with no React in it, so Jest can drive it with fake fetch/streams. + * + * Units: Mbps means decimal megabits per second (1 Mbps = 1,000,000 bits/s), the unit video + * bitrates are quoted in. MB/s is decimal megabytes. + */ +import {API_URI} from "../Vars"; + +export const SPEEDTEST_API = `${API_URI}/speedtest`; + +// The browser runs each throughput phase this long. The server clamps download at 15 s. +export const PHASE_SECONDS = 10; +// Throughput is tallied into buckets this wide; the live number and sparkline are per bucket. +export const BUCKET_MS = 250; +// TCP ramps up over the first second; the steady-state figure ignores it. +export const RAMP_MS = 1000; +export const PING_SAMPLES = 20; +export const PING_GAP_MS = 100; +export const PING_TIMEOUT_MS = 5000; +// One body reused for every upload POST. 1 MiB so a slow hotspot (a few Mbps) still completes +// several requests inside the phase; the server's per-request cap is 8 MiB. +export const UPLOAD_CHUNK_BYTES = 1024 * 1024; + +// Typical bitrates for a well-encoded video at each resolution. Deliberately round; the table +// says "about". +export const STREAM_TIERS = [ + {key: '480p', label: '480p (SD)', mbps: 2.5}, + {key: '720p', label: '720p (HD)', mbps: 5}, + {key: '1080p', label: '1080p (Full HD)', mbps: 8}, + {key: '1440p', label: '1440p (2K)', mbps: 16}, + {key: '2160p', label: '2160p (4K)', mbps: 25}, +]; + +export const bytesToMbps = (bytes, ms) => ms > 0 ? (bytes * 8) / (ms / 1000) / 1e6 : 0; + +export const mbpsToMBps = mbps => mbps / 8; + +export function formatMbps(mbps) { + if (mbps === null || mbps === undefined || !Number.isFinite(mbps)) { + return '—'; + } + if (mbps >= 100) { + return mbps.toFixed(0); + } + return mbps >= 10 ? mbps.toFixed(1) : mbps.toFixed(2); +} + +export function formatMs(ms) { + if (ms === null || ms === undefined || !Number.isFinite(ms)) { + return '—'; + } + return ms >= 100 ? ms.toFixed(0) : ms.toFixed(1); +} + +/** + * How many simultaneous streams of each tier fit in the measured download throughput. + */ +export function streamCapacity(downloadMbps) { + const mbps = Number.isFinite(downloadMbps) && downloadMbps > 0 ? downloadMbps : 0; + return STREAM_TIERS.map(tier => ({...tier, streams: Math.floor(mbps / tier.mbps)})); +} + +/** + * Average the per-bucket throughput after the ramp-up window. `samples` are + * `{ms, bytes}` buckets in order, `ms` being the bucket's end offset from the phase start. + * Falls back to the whole run when it was too short to have a steady state. + */ +export function steadyStateMbps(samples) { + if (!samples || samples.length === 0) { + return 0; + } + let steady = samples.filter(s => s.ms > RAMP_MS); + if (steady.length === 0) { + steady = samples; + } + const bytes = steady.reduce((acc, s) => acc + s.bytes, 0); + const first = steady[0]; + const start = first.ms - first.width; + const ms = steady[steady.length - 1].ms - start; + return bytesToMbps(bytes, ms); +} + +export const peakMbps = samples => Math.max(0, ...(samples || []).map(s => bytesToMbps(s.bytes, s.width))); + +export function pingStats(rtts) { + const good = (rtts || []).filter(Number.isFinite); + if (good.length === 0) { + return {min: null, median: null, jitter: null, lost: (rtts || []).length}; + } + const sorted = [...good].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + let jitter = 0; + if (good.length > 1) { + let diffs = 0; + for (let i = 1; i < good.length; i++) { + diffs += Math.abs(good[i] - good[i - 1]); + } + jitter = diffs / (good.length - 1); + } + return {min: sorted[0], median, jitter, lost: (rtts || []).length - good.length}; +} + +export function describePing(medianMs) { + if (medianMs === null || medianMs === undefined || !Number.isFinite(medianMs)) { + return {kind: 'warning', text: 'No response'}; + } + if (medianMs < 20) { + return {kind: 'success', text: 'Excellent'}; + } + if (medianMs < 60) { + return {kind: 'success', text: 'Good'}; + } + if (medianMs < 150) { + return {kind: 'warning', text: 'Fair'}; + } + return {kind: 'danger', text: 'Poor'}; +} + +/** + * NetworkManager's shared-connection mode (which `nmcli device wifi hotspot` uses) hands out + * 10.42.x.x addresses. + */ +export function isHotspotAddress(ip) { + if (!ip || typeof ip !== 'string') { + return false; + } + const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip; + return /^10\.42\.\d{1,3}\.\d{1,3}$/.test(v4); +} + +/** + * Accumulates byte counts into fixed-width buckets and reports them for the live display. + */ +export class Buckets { + constructor(width = BUCKET_MS, now = () => performance.now()) { + this.width = width; + this.now = now; + this.start = now(); + this.samples = []; + this.pending = 0; + this.total = 0; + } + + add(bytes) { + this.pending += bytes; + this.total += bytes; + this.flush(false); + } + + /** Close every bucket that has fully elapsed (or all of them when `force`). */ + flush(force) { + const elapsed = this.now() - this.start; + const closed = this.samples.length * this.width; + if (elapsed - closed >= this.width) { + // Every complete bucket since the last flush gets the pending bytes. Almost always + // this is one bucket; a stall spreads its bytes over the buckets it spanned. + const count = Math.floor((elapsed - closed) / this.width); + const per = this.pending / count; + for (let i = 1; i <= count; i++) { + this.samples.push({ms: closed + i * this.width, width: this.width, bytes: per}); + } + this.pending = 0; + } else if (force && this.pending > 0) { + const width = Math.max(1, elapsed - closed); + this.samples.push({ms: elapsed, width, bytes: this.pending}); + this.pending = 0; + } + } + + get elapsedMs() { + return this.now() - this.start; + } + + /** Mbps of the most recently closed bucket, for the big live number. */ + get currentMbps() { + const last = this.samples[this.samples.length - 1]; + return last ? bytesToMbps(last.bytes, last.width) : 0; + } +} + +const defaultDeps = () => ({ + fetch: (...args) => window.fetch(...args), + now: () => performance.now(), + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), +}); + +/** + * Time PING_SAMPLES sequential round trips. Calls `onSample(rtts)` after each. + * A sample that times out is recorded as `null`. + */ +export async function runPing({signal, onSample, samples = PING_SAMPLES, gapMs = PING_GAP_MS, deps} = {}) { + const {fetch, now, sleep} = {...defaultDeps(), ...deps}; + const rtts = []; + for (let i = 0; i < samples; i++) { + if (signal?.aborted) { + break; + } + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), PING_TIMEOUT_MS); + const onAbort = () => timeout.abort(); + signal?.addEventListener('abort', onAbort); + const start = now(); + try { + const response = await fetch(`${SPEEDTEST_API}/ping`, {cache: 'no-store', signal: timeout.signal}); + await response.arrayBuffer(); + rtts.push(response.ok ? now() - start : null); + } catch (e) { + if (signal?.aborted) { + break; + } + rtts.push(null); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + if (onSample) { + onSample([...rtts]); + } + if (i < samples - 1) { + await sleep(gapMs); + } + } + return pingStats(rtts); +} + +/** + * Read a download stream for `seconds`, tallying bytes into buckets. Calls `onProgress(buckets)` + * as data arrives. Resolves to `{mbps, peak, bytes, samples}`. + */ +export async function runDownload({signal, onProgress, seconds = PHASE_SECONDS, deps} = {}) { + const {fetch, now} = {...defaultDeps(), ...deps}; + const buckets = new Buckets(BUCKET_MS, now); + // The server also stops at `seconds`; the client deadline covers a slow last chunk. + const deadline = buckets.start + seconds * 1000; + const response = await fetch(`${SPEEDTEST_API}/download?seconds=${seconds}`, {cache: 'no-store', signal}); + if (!response.ok) { + throw new Error(`Download test failed: HTTP ${response.status}`); + } + const reader = response.body.getReader(); + try { + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + buckets.add(value.byteLength); + if (onProgress) { + onProgress(buckets); + } + if (now() >= deadline) { + break; + } + } + } finally { + try { + await reader.cancel(); + } catch (e) { + // Already closed. + } + } + buckets.flush(true); + return {mbps: steadyStateMbps(buckets.samples), peak: peakMbps(buckets.samples), bytes: buckets.total, samples: buckets.samples}; +} + +export function randomBlob(size = UPLOAD_CHUNK_BYTES) { + const bytes = new Uint8Array(size); + // getRandomValues refuses more than 64 KiB per call. + const step = 65536; + for (let offset = 0; offset < size; offset += step) { + window.crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + step, size))); + } + return new Blob([bytes], {type: 'application/octet-stream'}); +} + +/** + * POST the same random body over and over for `seconds`, tallying the bytes each request + * delivered. Sequential requests rather than a streamed body because Safari cannot stream a + * request body. A request still in flight when the phase deadline passes is aborted, so the + * phase ends on time on a slow link rather than after the current body finishes; only completed + * requests count. Resolves to `{mbps, peak, bytes, samples}`. + */ +export async function runUpload({signal, onProgress, seconds = PHASE_SECONDS, body, deps} = {}) { + const {fetch, now} = {...defaultDeps(), ...deps}; + const blob = body || randomBlob(); + const buckets = new Buckets(BUCKET_MS, now); + const deadline = buckets.start + seconds * 1000; + // Aborts the in-flight request at the deadline or when the caller cancels. + const phase = new AbortController(); + const onAbort = () => phase.abort(); + signal?.addEventListener('abort', onAbort); + const timer = setTimeout(() => phase.abort(), seconds * 1000); + try { + while (now() < deadline && !phase.signal.aborted) { + let response; + try { + response = await fetch(`${SPEEDTEST_API}/upload`, { + method: 'POST', body: blob, cache: 'no-store', signal: phase.signal, + }); + } catch (e) { + if (phase.signal.aborted && !signal?.aborted) { + // The deadline cut this request short; the phase is simply over. + break; + } + throw e; + } + if (!response.ok) { + throw new Error(`Upload test failed: HTTP ${response.status}`); + } + const json = await response.json(); + buckets.add(json.bytes); + if (onProgress) { + onProgress(buckets); + } + } + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + buckets.flush(true); + return {mbps: steadyStateMbps(buckets.samples), peak: peakMbps(buckets.samples), bytes: buckets.total, samples: buckets.samples}; +} + +export async function fetchInfo({deps} = {}) { + const {fetch} = {...defaultDeps(), ...deps}; + const response = await fetch(`${SPEEDTEST_API}/info`, {cache: 'no-store'}); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.json(); +} + +/** "2 other tests" given the counter sampled before and during our own run. */ +export function otherActiveTests(before, during) { + const b = Number.isFinite(before) ? before : 0; + // While our own download or upload is in flight the counter includes us. + const d = Number.isFinite(during) ? Math.max(0, during - 1) : 0; + return Math.max(b, d); +} diff --git a/app/src/components/calculators/speedtest.test.js b/app/src/components/calculators/speedtest.test.js new file mode 100644 index 00000000..df058792 --- /dev/null +++ b/app/src/components/calculators/speedtest.test.js @@ -0,0 +1,318 @@ +import { + Buckets, + bytesToMbps, + describePing, + formatMbps, + isHotspotAddress, + otherActiveTests, + peakMbps, + pingStats, + runDownload, + runPing, + runUpload, + steadyStateMbps, + streamCapacity, +} from "./speedtest"; + +// A controllable clock so bucket boundaries are deterministic. +const makeClock = () => { + let t = 0; + const now = () => t; + now.advance = ms => { + t += ms; + }; + return now; +}; + +describe('bytesToMbps', () => { + test('converts bytes over milliseconds to decimal megabits per second', () => { + // 1,250,000 bytes in one second is 10,000,000 bits/s = 10 Mbps. + expect(bytesToMbps(1_250_000, 1000)).toBeCloseTo(10, 6); + expect(bytesToMbps(1_250_000, 500)).toBeCloseTo(20, 6); + }); + + test('zero or negative time is zero, not infinity', () => { + expect(bytesToMbps(100, 0)).toBe(0); + }); +}); + +describe('formatMbps', () => { + test('shows more digits for slower links', () => { + expect(formatMbps(543.21)).toBe('543'); + expect(formatMbps(54.321)).toBe('54.3'); + expect(formatMbps(5.4321)).toBe('5.43'); + }); + + test('missing values are a dash', () => { + expect(formatMbps(null)).toBe('—'); + expect(formatMbps(NaN)).toBe('—'); + }); +}); + +describe('streamCapacity', () => { + test('floors the measured throughput against each tier bitrate', () => { + const rows = Object.fromEntries(streamCapacity(8).map(r => [r.key, r.streams])); + expect(rows).toEqual({'480p': 3, '720p': 1, '1080p': 1, '1440p': 0, '2160p': 0}); + }); + + test('a fast link streams many at once', () => { + const rows = Object.fromEntries(streamCapacity(250).map(r => [r.key, r.streams])); + expect(rows['2160p']).toBe(10); + expect(rows['480p']).toBe(100); + }); + + test('nothing measured means zero streams everywhere', () => { + expect(streamCapacity(0).every(r => r.streams === 0)).toBe(true); + expect(streamCapacity(NaN).every(r => r.streams === 0)).toBe(true); + }); +}); + +describe('steadyStateMbps', () => { + // 250 ms buckets; 312,500 bytes per bucket is 10 Mbps. + const bucket = (ms, bytes) => ({ms, width: 250, bytes}); + + test('ignores the ramp-up second and averages the rest', () => { + const samples = [ + bucket(250, 0), bucket(500, 0), bucket(750, 0), bucket(1000, 0), // slow start + bucket(1250, 312_500), bucket(1500, 312_500), bucket(1750, 312_500), bucket(2000, 312_500), + ]; + expect(steadyStateMbps(samples)).toBeCloseTo(10, 6); + }); + + test('falls back to the whole run when it never left the ramp window', () => { + expect(steadyStateMbps([bucket(250, 312_500), bucket(500, 312_500)])).toBeCloseTo(10, 6); + }); + + test('empty run is zero', () => { + expect(steadyStateMbps([])).toBe(0); + expect(steadyStateMbps(undefined)).toBe(0); + }); + + test('peak is the fastest single bucket', () => { + expect(peakMbps([bucket(250, 312_500), bucket(500, 625_000)])).toBeCloseTo(20, 6); + }); +}); + +describe('pingStats', () => { + test('reports min, median and mean successive jitter', () => { + const stats = pingStats([10, 12, 30, 14]); + expect(stats.min).toBe(10); + expect(stats.median).toBe(13); + // |12-10| + |30-12| + |14-30| = 2 + 18 + 16 = 36; / 3 = 12. + expect(stats.jitter).toBeCloseTo(12, 6); + expect(stats.lost).toBe(0); + }); + + test('timeouts are counted as lost and excluded from the numbers', () => { + const stats = pingStats([10, null, 20]); + expect(stats.lost).toBe(1); + expect(stats.median).toBe(15); + }); + + test('all lost gives nulls', () => { + expect(pingStats([null, null])).toEqual({min: null, median: null, jitter: null, lost: 2}); + }); +}); + +describe('describePing', () => { + test('thresholds', () => { + expect(describePing(5).text).toBe('Excellent'); + expect(describePing(40).text).toBe('Good'); + expect(describePing(100).text).toBe('Fair'); + expect(describePing(400).text).toBe('Poor'); + expect(describePing(null).text).toBe('No response'); + }); +}); + +describe('isHotspotAddress', () => { + test("matches NetworkManager's shared range", () => { + expect(isHotspotAddress('10.42.0.7')).toBe(true); + expect(isHotspotAddress('10.42.1.200')).toBe(true); + expect(isHotspotAddress('::ffff:10.42.0.7')).toBe(true); + }); + + test('rejects LAN, loopback and garbage', () => { + expect(isHotspotAddress('10.0.0.5')).toBe(false); + expect(isHotspotAddress('192.168.1.10')).toBe(false); + expect(isHotspotAddress('::1')).toBe(false); + expect(isHotspotAddress('10.420.0.1')).toBe(false); + expect(isHotspotAddress(undefined)).toBe(false); + }); +}); + +describe('otherActiveTests', () => { + test('subtracts our own in-flight request from the during sample', () => { + expect(otherActiveTests(0, 1)).toBe(0); + expect(otherActiveTests(0, 3)).toBe(2); + expect(otherActiveTests(2, 1)).toBe(2); + expect(otherActiveTests(undefined, undefined)).toBe(0); + }); +}); + +describe('Buckets', () => { + test('closes a bucket once its width has elapsed', () => { + const now = makeClock(); + const b = new Buckets(250, now); + b.add(100); + expect(b.samples).toHaveLength(0); + now.advance(250); + b.add(200); + expect(b.samples).toHaveLength(1); + expect(b.samples[0]).toEqual({ms: 250, width: 250, bytes: 300}); + expect(b.total).toBe(300); + }); + + test('a stall spreads its bytes over the buckets it spanned', () => { + const now = makeClock(); + const b = new Buckets(250, now); + now.advance(1000); + b.add(400); + expect(b.samples.map(s => s.bytes)).toEqual([100, 100, 100, 100]); + }); + + test('force flush closes a partial bucket', () => { + const now = makeClock(); + const b = new Buckets(250, now); + now.advance(100); + b.add(50); + b.flush(true); + expect(b.samples).toHaveLength(1); + expect(b.samples[0].width).toBe(100); + expect(b.samples[0].bytes).toBe(50); + }); +}); + +// A fake fetch that serves a ReadableStream of `chunks`, advancing the clock per chunk. +const streamingFetch = (chunks, now, perChunkMs) => jest.fn(async () => { + let i = 0; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (i >= chunks.length) { + return {done: true}; + } + now.advance(perChunkMs); + return {done: false, value: new Uint8Array(chunks[i++])}; + }, + cancel: async () => { + }, + }), + }, + }; +}); + +describe('runDownload', () => { + test('totals the bytes and reports steady-state throughput', async () => { + const now = makeClock(); + // 12 chunks of 312,500 bytes, one every 250 ms: 10 Mbps for 3 s. + const fetch = streamingFetch(Array(12).fill(312_500), now, 250); + const progress = jest.fn(); + const result = await runDownload({seconds: 10, onProgress: progress, deps: {fetch, now}}); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/speedtest/download?seconds=10'), + expect.objectContaining({cache: 'no-store'})); + expect(result.bytes).toBe(12 * 312_500); + expect(result.mbps).toBeCloseTo(10, 3); + expect(progress).toHaveBeenCalled(); + }); + + test('stops reading at the client deadline even if the server keeps sending', async () => { + const now = makeClock(); + const fetch = streamingFetch(Array(100).fill(1000), now, 250); + const result = await runDownload({seconds: 1, deps: {fetch, now}}); + // 1 s deadline / 250 ms per chunk = 4 chunks read. + expect(result.bytes).toBe(4000); + }); + + test('a non-2xx response is an error', async () => { + const fetch = jest.fn(async () => ({ok: false, status: 502})); + await expect(runDownload({deps: {fetch, now: makeClock()}})).rejects.toThrow('HTTP 502'); + }); +}); + +describe('runUpload', () => { + test('posts until the deadline and sums the server-reported bytes', async () => { + const now = makeClock(); + const fetch = jest.fn(async (url, init) => { + now.advance(500); + return {ok: true, status: 200, json: async () => ({bytes: init.body.size, seconds: 0.5})}; + }); + const body = {size: 625_000}; // 625,000 bytes per 500 ms = 10 Mbps + const result = await runUpload({seconds: 2, body, deps: {fetch, now}}); + expect(fetch).toHaveBeenCalledTimes(4); + expect(fetch.mock.calls[0][1]).toEqual(expect.objectContaining({method: 'POST', body})); + expect(result.bytes).toBe(4 * 625_000); + expect(result.mbps).toBeCloseTo(10, 3); + }); + + test('a request still in flight at the deadline is aborted and the phase ends on time', async () => { + jest.useFakeTimers(); + try { + const now = makeClock(); + let calls = 0; + const fetch = jest.fn((url, init) => new Promise((resolve, reject) => { + calls++; + if (calls === 1) { + // Fast first request. + now.advance(200); + resolve({ok: true, status: 200, json: async () => ({bytes: 1000, seconds: 0.2})}); + return; + } + // Second request never completes on its own: it must be aborted at the deadline. + init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + })); + const promise = runUpload({seconds: 1, body: {size: 1000}, deps: {fetch, now}}); + // Let the first request settle and the second start. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + now.advance(2000); + jest.advanceTimersByTime(1000); + const result = await promise; + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1][1].signal.aborted).toBe(true); + // Only the completed request counts. + expect(result.bytes).toBe(1000); + } finally { + jest.useRealTimers(); + } + }); + + test('an aborted signal ends the loop', async () => { + const now = makeClock(); + const controller = new AbortController(); + const fetch = jest.fn(async () => { + now.advance(100); + controller.abort(); + return {ok: true, status: 200, json: async () => ({bytes: 10, seconds: 0.1})}; + }); + const result = await runUpload({seconds: 10, body: {size: 10}, signal: controller.signal, deps: {fetch, now}}); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.bytes).toBe(10); + }); +}); + +describe('runPing', () => { + test('times each round trip and records timeouts as lost', async () => { + const now = makeClock(); + let call = 0; + const fetch = jest.fn(async () => { + call++; + now.advance(call === 2 ? 30 : 10); + if (call === 3) { + throw new Error('network'); + } + return {ok: true, arrayBuffer: async () => new ArrayBuffer(0)}; + }); + const sleep = jest.fn(async () => { + }); + const stats = await runPing({samples: 4, deps: {fetch, now, sleep}}); + expect(fetch).toHaveBeenCalledTimes(4); + expect(stats.lost).toBe(1); + expect(stats.min).toBe(10); + expect(stats.median).toBe(10); + expect(sleep).toHaveBeenCalledTimes(3); + }); +}); diff --git a/app/src/components/ui/Sparkline.tsx b/app/src/components/ui/Sparkline.tsx new file mode 100644 index 00000000..9e6f4bb0 --- /dev/null +++ b/app/src/components/ui/Sparkline.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +export interface SparklineProps { + /** The series, oldest first. Non-finite values are drawn as gaps. */ + values: Array; + /** Fixed vertical ceiling; defaults to the series maximum so the line fills the box. */ + max?: number; + /** Token color name (`blue`, `green`, `warning`…). Resolves through `var(--name)`. */ + color?: string; + /** Pixel height. Width always fills the container. */ + height?: number; + /** Draw a faint fill under the line. */ + area?: boolean; + /** Accessible summary, e.g. "Download throughput over the last 10 seconds". */ + label?: string; + className?: string; +} + +/** A tiny inline-SVG line chart with no axes; the figure beside it carries the scale. */ +export function Sparkline({values, max, color = 'blue', height = 48, area = true, label, className}: SparklineProps) { + const width = 100; // viewBox units; the SVG stretches to its container. + const finite = values.map(v => (typeof v === 'number' && Number.isFinite(v) ? v : null)); + const ceiling = max && max > 0 ? max : Math.max(1e-9, ...finite.map(v => v ?? 0)); + const n = finite.length; + const step = n > 1 ? width / (n - 1) : width; + const pad = 2; + const usable = height - pad * 2; + + // Break the polyline at gaps so a lost sample shows as missing rather than a plunge. + const segments: string[] = []; + let current: string[] = []; + finite.forEach((v, i) => { + if (v === null) { + if (current.length) { + segments.push(current.join(' ')); + current = []; + } + return; + } + const x = n > 1 ? i * step : width; + const y = pad + usable - (Math.min(v, ceiling) / ceiling) * usable; + current.push(`${x.toFixed(2)},${y.toFixed(2)}`); + }); + if (current.length) { + segments.push(current.join(' ')); + } + + const stroke = `var(--${color})`; + + return + {/* Baseline so an empty or flat-zero series still shows something is being drawn. */} + + {area && segments.map((points, i) => { + const coords = points.split(' '); + const first = coords[0].split(',')[0]; + const last = coords[coords.length - 1].split(',')[0]; + return ; + })} + {segments.map((points, i) => )} + ; +} diff --git a/app/src/components/ui/index.ts b/app/src/components/ui/index.ts index bac5c82e..da22a30a 100644 --- a/app/src/components/ui/index.ts +++ b/app/src/components/ui/index.ts @@ -15,6 +15,7 @@ export * from './scale'; export * from './Icon'; export * from './Button'; export * from './Feedback'; +export * from './Sparkline'; export * from './Surfaces'; export * from './DataTable'; export * from './Navigation'; diff --git a/docker/web/Caddyfile b/docker/web/Caddyfile index a7b095c8..1d745032 100644 --- a/docker/web/Caddyfile +++ b/docker/web/Caddyfile @@ -44,6 +44,13 @@ redir * /controller/ 301 } + # Speed test streams generated bytes; never buffer it. + handle /api/speedtest/* { + reverse_proxy api:8081 { + flush_interval -1 + } + } + # Main API. handle /api/* { reverse_proxy api:8081 diff --git a/etc/raspberrypios/Caddyfile b/etc/raspberrypios/Caddyfile index 95989f58..5a6f2484 100644 --- a/etc/raspberrypios/Caddyfile +++ b/etc/raspberrypios/Caddyfile @@ -44,6 +44,13 @@ redir * /controller/ 301 } + # Speed test streams generated bytes; never buffer it. + handle /api/speedtest/* { + reverse_proxy 0.0.0.0:8081 { + flush_interval -1 + } + } + # Main API. handle /api/* { reverse_proxy 0.0.0.0:8081 diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index ac80ad10..e1c0b73b 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -74,6 +74,9 @@ def attach_shared_contexts(app: Sanic): # held until the API restarts; that is accepted, transcoding is not critical work. app.shared_ctx.transcode_lock = multiprocessing.Lock() + # Speed tests in flight across all workers (wrolpi.speedtest). Informational only, no lock. + app.shared_ctx.speedtest_active = multiprocessing.Value(ctypes.c_int, 0) + # Switches app.shared_ctx.switches = manager.dict() app.shared_ctx.switches_lock = multiprocessing.Lock() @@ -125,6 +128,7 @@ def reset_shared_contexts(app: Sanic): app.shared_ctx.refresh.clear() app.shared_ctx.uploaded_files.clear() app.shared_ctx.status.clear() + app.shared_ctx.speedtest_active.value = 0 app.shared_ctx.status.update(dict( cpu_stats=dict(), load_stats=dict(), diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 0e76f5e8..95d36512 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -36,6 +36,7 @@ from wrolpi.events import get_events, Events from wrolpi.files import files_bp from wrolpi.files.lib import get_file_statistics, search_file_suggestion_count from wrolpi.log_levels import int_to_name +from wrolpi.speedtest import speedtest_bp from wrolpi.tags import Tag from wrolpi.vars import DOCKERIZED, IS_RPI, IS_RPI4, IS_RPI5, API_HOST, API_PORT, API_WORKERS, API_DEBUG, \ API_ACCESS_LOG, truthy_arg, API_AUTO_RELOAD @@ -44,6 +45,9 @@ from wrolpi.version import __version__ logger = logger.getChild(__name__) api_app.config.FALLBACK_ERROR_FORMAT = 'json' +# Caddy is the single proxy in front of the API; trust its X-Forwarded-For so request.remote_addr +# is the browser's address (used by the speed test). +api_app.config.PROXIES_COUNT = 1 api_bp = Blueprint('RootAPI', url_prefix='/api') @@ -57,6 +61,7 @@ api_app.blueprint(files_bp) api_app.blueprint(flasher_bp) api_app.blueprint(inventory_bp) api_app.blueprint(map_bp) +api_app.blueprint(speedtest_bp) api_app.blueprint(videos_bp) api_app.blueprint(zim_bp) diff --git a/wrolpi/speedtest.py b/wrolpi/speedtest.py new file mode 100644 index 00000000..86678602 --- /dev/null +++ b/wrolpi/speedtest.py @@ -0,0 +1,173 @@ +"""Speed test endpoints: ping, a bounded download stream, a bounded upload sink, and context. + +Nothing is stored. Several clients may test at once; the server only counts tests in flight. +""" +import os +import time +from http import HTTPStatus +from typing import Iterator, Optional + +from sanic import Blueprint +from sanic.request import Request +from sanic_ext.extensions.openapi import openapi + +from wrolpi.api_utils import json_response, api_app +from wrolpi.common import logger +from wrolpi.errors import ValidationError, APIError + +logger = logger.getChild(__name__) + +speedtest_bp = Blueprint('SpeedTest', url_prefix='/api/speedtest') + +# Bounds on how long a download stream may hold a worker. +MIN_DOWNLOAD_SECONDS = 0.1 +DEFAULT_DOWNLOAD_SECONDS = 10.0 +MAX_DOWNLOAD_SECONDS = 15.0 +# Bytes sent per `await response.send()`. Large enough that Python overhead does not cap a fast +# LAN, small enough that the loop yields often. +CHUNK_SIZE = 256 * 1024 +# The browser uploads in sequential POSTs of a few MiB. Both caps exist so a request cannot hold a +# worker: size alone does not, a slow or paused body could trickle in for minutes. +MAX_UPLOAD_CHUNK = 8 * 1024 * 1024 +MAX_UPLOAD_SECONDS = 15.0 + +# One random buffer built at import; chunks are slices of it so no bytes are generated per +# request. Random so no proxy or compression can flatter the result. A whole multiple of +# CHUNK_SIZE so every slice is a full chunk. +_RANDOM = os.urandom(CHUNK_SIZE * 16) + +NO_STORE_HEADERS = { + 'Cache-Control': 'no-store', + # Ask proxies not to buffer the stream. + 'X-Accel-Buffering': 'no', +} + + +class UploadTooLarge(APIError): + code = 'UPLOAD_TOO_LARGE' + summary = f'Speed test upload requests are limited to {MAX_UPLOAD_CHUNK} bytes' + status_code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE + + +class UploadTooSlow(APIError): + code = 'UPLOAD_TOO_SLOW' + summary = f'Speed test upload requests must finish within {MAX_UPLOAD_SECONDS} seconds' + status_code = HTTPStatus.REQUEST_TIMEOUT + + +def clamp_seconds(value: Optional[str]) -> float: + """Parse the requested download duration and keep it within the server's bounds.""" + if value is None or value == '': + return DEFAULT_DOWNLOAD_SECONDS + try: + seconds = float(value) + except (TypeError, ValueError): + raise ValidationError(f'seconds must be a number, got {value!r}') + if seconds != seconds: # NaN + raise ValidationError('seconds must be a number, got NaN') + if seconds < 0: + raise ValidationError('seconds must not be negative') + return max(MIN_DOWNLOAD_SECONDS, min(seconds, MAX_DOWNLOAD_SECONDS)) + + +def iter_chunks() -> Iterator[bytes]: + """Yield full CHUNK_SIZE slices of the random buffer forever, wrapping around.""" + offset = 0 + while True: + yield _RANDOM[offset:offset + CHUNK_SIZE] + offset = (offset + CHUNK_SIZE) % len(_RANDOM) + + +def _counter(): + """The cross-worker count of speed tests in flight (created pre-fork in wrolpi.contexts).""" + return api_app.shared_ctx.speedtest_active + + +def _adjust_active(delta: int): + counter = _counter() + with counter.get_lock(): + counter.value = max(0, counter.value + delta) + + +def get_client_ip(request: Request) -> str: + """The address Caddy forwarded, falling back to the socket peer for direct requests.""" + return request.remote_addr or request.ip + + +@speedtest_bp.get('/ping') +@openapi.definition( + summary='Tiny response the browser times to measure round trip latency.', +) +async def ping(_: Request): + return json_response(dict(now=time.monotonic() * 1000), headers=NO_STORE_HEADERS) + + +@speedtest_bp.get('/info') +@openapi.definition( + summary='Context for a speed test: the client address the server saw and how many tests are running.', +) +async def info(request: Request): + return json_response(dict( + client_ip=get_client_ip(request), + active_tests=_counter().value, + max_download_seconds=MAX_DOWNLOAD_SECONDS, + max_upload_chunk=MAX_UPLOAD_CHUNK, + max_upload_seconds=MAX_UPLOAD_SECONDS, + ), headers=NO_STORE_HEADERS) + + +@speedtest_bp.get('/download') +@openapi.definition( + summary='Stream random bytes for `seconds` (clamped) so the browser can measure download throughput.', +) +async def download(request: Request): + seconds = clamp_seconds(request.args.get('seconds')) + response = await request.respond(content_type='application/octet-stream', headers=NO_STORE_HEADERS) + _adjust_active(1) + try: + deadline = time.monotonic() + seconds + for chunk in iter_chunks(): + if time.monotonic() >= deadline: + break + await response.send(chunk) + await response.eof() + except Exception as e: + # The browser aborts the fetch when its own timer ends; that is normal. + logger.debug(f'speed test download ended early: {e!r}') + finally: + _adjust_active(-1) + + +async def drain_upload(request: Request) -> int: + """Read and discard the request body, enforcing both the size and the time cap.""" + deadline = time.monotonic() + MAX_UPLOAD_SECONDS + total = 0 + while True: + body = await request.stream.read() + if body is None: + return total + total += len(body) + if total > MAX_UPLOAD_CHUNK: + raise UploadTooLarge() + if time.monotonic() > deadline: + raise UploadTooSlow() + + +@speedtest_bp.post('/upload', stream=True) +@openapi.definition( + summary='Read and discard the request body, reporting how many bytes arrived and how long it took.', +) +async def upload(request: Request): + declared = request.headers.get('content-length') + if declared and declared.isdigit() and int(declared) > MAX_UPLOAD_CHUNK: + raise UploadTooLarge() + + _adjust_active(1) + try: + start = time.monotonic() + total = await drain_upload(request) + seconds = time.monotonic() - start + finally: + _adjust_active(-1) + + return json_response(dict(bytes=total, seconds=seconds), headers=NO_STORE_HEADERS) diff --git a/wrolpi/test/test_speedtest.py b/wrolpi/test/test_speedtest.py new file mode 100644 index 00000000..0e3d1db1 --- /dev/null +++ b/wrolpi/test/test_speedtest.py @@ -0,0 +1,190 @@ +import time +from http import HTTPStatus + +import pytest + +from wrolpi import speedtest +from wrolpi.api_utils import api_app + + +def _active() -> int: + return api_app.shared_ctx.speedtest_active.value + + +def test_clamp_seconds(): + assert speedtest.clamp_seconds(None) == speedtest.DEFAULT_DOWNLOAD_SECONDS + assert speedtest.clamp_seconds('') == speedtest.DEFAULT_DOWNLOAD_SECONDS + assert speedtest.clamp_seconds('2.5') == 2.5 + # Too long is clamped to the ceiling, too short to the floor. + assert speedtest.clamp_seconds('999') == speedtest.MAX_DOWNLOAD_SECONDS + assert speedtest.clamp_seconds('0') == speedtest.MIN_DOWNLOAD_SECONDS + with pytest.raises(speedtest.ValidationError): + speedtest.clamp_seconds('abc') + with pytest.raises(speedtest.ValidationError, match='negative'): + speedtest.clamp_seconds('-1') + with pytest.raises(speedtest.ValidationError, match='NaN'): + speedtest.clamp_seconds('nan') + + +def test_chunks_never_empty(): + """The chunk generator wraps around the random buffer without ever yielding a short chunk.""" + chunks = speedtest.iter_chunks() + seen = [next(chunks) for _ in range(len(speedtest._RANDOM) // speedtest.CHUNK_SIZE + 3)] + assert all(len(i) == speedtest.CHUNK_SIZE for i in seen) + # Wrapped around to the start. + assert seen[0] == seen[len(speedtest._RANDOM) // speedtest.CHUNK_SIZE] + + +@pytest.mark.asyncio +async def test_ping(async_client): + request, response = await async_client.get('/api/speedtest/ping') + assert response.status_code == HTTPStatus.OK + assert isinstance(response.json['now'], (int, float)) + assert response.headers['Cache-Control'] == 'no-store' + + +@pytest.mark.asyncio +async def test_download_streams_for_requested_seconds(async_client): + assert _active() == 0 + start = time.monotonic() + request, response = await async_client.get('/api/speedtest/download?seconds=0.3') + elapsed = time.monotonic() - start + assert response.status_code == HTTPStatus.OK + assert response.headers['Content-Type'] == 'application/octet-stream' + assert response.headers['Cache-Control'] == 'no-store' + assert len(response.body) >= speedtest.CHUNK_SIZE + assert len(response.body) % speedtest.CHUNK_SIZE == 0 + # Ran for about the requested duration. Loose upper bound for slow CI. + assert 0.25 <= elapsed < 5 + # Counter is released when the stream finishes. + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_download_clamps_seconds(async_client): + """A client cannot hold a worker longer than MAX_DOWNLOAD_SECONDS.""" + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'MAX_DOWNLOAD_SECONDS', 0.2) + start = time.monotonic() + request, response = await async_client.get('/api/speedtest/download?seconds=999') + elapsed = time.monotonic() - start + assert response.status_code == HTTPStatus.OK + assert elapsed < 3 + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_download_invalid_seconds(async_client): + request, response = await async_client.get('/api/speedtest/download?seconds=abc') + assert response.status_code == HTTPStatus.BAD_REQUEST + request, response = await async_client.get('/api/speedtest/download?seconds=-1') + assert response.status_code == HTTPStatus.BAD_REQUEST + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_download_client_disconnect_releases_counter(async_client): + """The active-test counter is released even when the stream dies part way through.""" + + def broken_chunks(): + yield speedtest._RANDOM[:speedtest.CHUNK_SIZE] + raise ConnectionResetError('client went away') + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'iter_chunks', broken_chunks) + try: + await async_client.get('/api/speedtest/download?seconds=0.5') + except Exception: + # The test client may surface the broken stream; the counter is what matters. + pass + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_upload_counts_and_discards(async_client): + body = b'x' * (1024 * 1024) + request, response = await async_client.post('/api/speedtest/upload', content=body) + assert response.status_code == HTTPStatus.OK + assert response.json['bytes'] == len(body) + assert response.json['seconds'] >= 0 + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_upload_too_large(async_client): + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'MAX_UPLOAD_CHUNK', 1024) + body = b'x' * 2048 + request, response = await async_client.post('/api/speedtest/upload', content=body) + assert response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_upload_too_large_mid_stream_releases_counter(async_client): + """A body that only reveals its size while streaming is refused, and the counter is released.""" + + async def body(): + yield b'x' * 1024 + yield b'x' * 1024 + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'MAX_UPLOAD_CHUNK', 1024) + request, response = await async_client.post('/api/speedtest/upload', content=body()) + assert response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_upload_too_slow(async_client): + """A body that trickles in past the time cap is refused, and the counter is released.""" + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'MAX_UPLOAD_SECONDS', 0) + request, response = await async_client.post('/api/speedtest/upload', content=b'x' * 2048) + assert response.status_code == HTTPStatus.REQUEST_TIMEOUT + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_upload_broken_stream_releases_counter(async_client): + """The counter is released when reading the body blows up part way through.""" + + async def broken(_request): + raise ConnectionResetError('client went away') + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(speedtest, 'drain_upload', broken) + try: + await async_client.post('/api/speedtest/upload', content=b'x' * 1024) + except Exception: + pass + assert _active() == 0 + + +@pytest.mark.asyncio +async def test_info(async_client): + request, response = await async_client.get('/api/speedtest/info') + assert response.status_code == HTTPStatus.OK + assert response.json['client_ip'] + assert response.json['active_tests'] == 0 + assert response.json['max_download_seconds'] == speedtest.MAX_DOWNLOAD_SECONDS + assert response.json['max_upload_chunk'] == speedtest.MAX_UPLOAD_CHUNK + assert response.json['max_upload_seconds'] == speedtest.MAX_UPLOAD_SECONDS + + # Caddy forwards the real client address; Sanic is configured to trust one proxy hop. + request, response = await async_client.get('/api/speedtest/info', + headers={'X-Forwarded-For': '10.42.0.7'}) + assert response.json['client_ip'] == '10.42.0.7' + + +def test_speedtest_counter_in_shared_context(): + """The counter must be created pre-fork so all Sanic workers share it.""" + from sanic import Sanic + from wrolpi.contexts import attach_shared_contexts, reset_shared_contexts + + test_app = Sanic(f'test_context_{id(test_speedtest_counter_in_shared_context)}') + attach_shared_contexts(test_app) + assert test_app.shared_ctx.speedtest_active.value == 0 + test_app.shared_ctx.speedtest_active.value = 3 + reset_shared_contexts(test_app) + assert test_app.shared_ctx.speedtest_active.value == 0