From 26c716aa872bc35853bc723ad2f7901016f4ee47 Mon Sep 17 00:00:00 2001 From: Tiago Dias Date: Thu, 29 Jan 2026 16:36:47 +0000 Subject: [PATCH] Add bandwidthAbortRequestDuration flag to abort test early in case a measurement exceeds a certain time limit --- README.md | 1 + rollup.config.js | 3 +- src/config/defaultConfig.js | 5 +- .../BandwidthEngine/BandwidthEngine.js | 54 +++++++++++++------ src/index.d.ts | 1 + src/index.js | 4 ++ 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9127ccdf..0a49faf4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ new SpeedTest({ configOptions }) | **measureUploadLoadedLatency**: *boolean* | Whether to perform additional latency measurements simultaneously with upload requests, to measure loaded latency (during upload). | `true` | | **loadedLatencyThrottle**: *number* | Time interval to wait in between loaded latency requests (in milliseconds). | 400 | | **bandwidthFinishRequestDuration**: *number* | The minimum duration (in milliseconds) to reach in download/upload measurement sets for halting further measurements with larger file sizes in the same direction. | 1000 | +| **bandwidthAbortRequestDuration**: *number* | The minimum duration (in milliseconds) to reach in download/upload measurement sets for aborting the test early | 45000 | | **estimatedServerTime**: *number* | If the download/upload APIs do not return a server-timing response header containing the time spent in the server, this fixed value (in milliseconds) will be subtracted from all time-to-first-byte calculations. | 10 | | **latencyPercentile**: *number* | The percentile (between 0 and 1) used to calculate latency from a set of measurements. | 0.5 | | **bandwidthPercentile**: *number* | The percentile (between 0 and 1) used to calculate bandwidth from a set of measurements. | 0.9 | diff --git a/rollup.config.js b/rollup.config.js index c15f4575..1fff1859 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -14,7 +14,8 @@ export default [ output: [ { format: 'es', - file: `dist/${fileName}.js` + file: `dist/${fileName}.js`, + sourcemap: true } ], external: [...Object.keys(dependencies || {})], diff --git a/src/config/defaultConfig.js b/src/config/defaultConfig.js index 73a1ab40..a64b4f42 100644 --- a/src/config/defaultConfig.js +++ b/src/config/defaultConfig.js @@ -45,9 +45,12 @@ export default { measureDownloadLoadedLatency: true, measureUploadLoadedLatency: true, loadedLatencyThrottle: 400, // ms in between loaded latency requests - bandwidthFinishRequestDuration: 1000, // download/upload duration (ms) to reach for stopping further measurements + bandwidthFinishRequestDuration: 1000, // download/upload duration (ms) to reach for stopping further measurements of that type estimatedServerTime: 10, // ms to discount from latency calculation (if not present in response headers) + // Test abort + bandwidthAbortRequestDuration: 45000, // download/upload duration (ms) to abort measurement early and stop further measurements of that type + // Result interpretation latencyPercentile: 0.5, // Percentile used to calculate latency from a set of measurements bandwidthPercentile: 0.9, // Percentile used to calculate bandwidth from a set of measurements diff --git a/src/engines/BandwidthEngine/BandwidthEngine.js b/src/engines/BandwidthEngine/BandwidthEngine.js index 31974122..ea07f70c 100644 --- a/src/engines/BandwidthEngine/BandwidthEngine.js +++ b/src/engines/BandwidthEngine/BandwidthEngine.js @@ -88,6 +88,7 @@ class BandwidthMeasurementEngine { } finishRequestDuration = 1000; // download/upload duration (ms) to reach for stopping further measurements + abortRequestDuration = 0; getServerTime = cfGetServerTime; // method to extract server time from response #responseHook = r => r; // pipe-through of response objects @@ -118,7 +119,6 @@ class BandwidthMeasurementEngine { // Public methods pause() { - clearTimeout(this.#currentNextMsmTimeoutId); this.#cancelCurrentMeasurement(); this.#setRunning(false); } @@ -144,8 +144,12 @@ class BandwidthMeasurementEngine { #minDuration = -Infinity; // of current measurement #throttleMs = 0; #estimatedServerTime = 0; - #currentFetchPromise = undefined; - #currentNextMsmTimeoutId = undefined; + + /** + * Aborts the current measurement. + * @type AbortController + */ + #currentAbortController = undefined; // Internal methods #setRunning(running) { @@ -267,8 +271,29 @@ class BandwidthMeasurementEngine { this.#fetchOptions ); + // AbortController and timeout is shared between all retries + if (this.#retries === 0) { + this.#currentAbortController = new AbortController(); + if (this.abortRequestDuration) { + const abortTimeout = setTimeout(() => { + this.#cancelCurrentMeasurement(); + this.#retries = 0; + this.#setRunning(false); + this.#onConnectionError( + `${isDown ? 'Download' : 'Upload'} measurement of ${numBytes} bytes aborted. Measurement exceeded bandwidthAbortRequestDuration (${this.abortRequestDuration}ms)` + ); + }, this.abortRequestDuration); + this.#currentAbortController.signal.addEventListener('abort', () => + clearTimeout(abortTimeout) + ); + } + } + let serverTime; - const curPromise = (this.#currentFetchPromise = fetch(url, fetchOpt) + fetch(url, { + ...fetchOpt, + signal: this.#currentAbortController.signal + }) .then(r => { if (r.ok) return r; throw Error(r.statusText); @@ -289,12 +314,7 @@ class BandwidthMeasurementEngine { return body; }) ) - .then((_, reject) => { - if (curPromise._cancel) { - reject('cancelled'); - return; - } - + .then(() => { const perf = performance.getEntriesByName(url).slice(-1)[0]; // get latest perf timing const timing = { transferSize: perf.transferSize, @@ -343,16 +363,21 @@ class BandwidthMeasurementEngine { this.#retries = 0; if (this.#throttleMs) { - this.#currentNextMsmTimeoutId = setTimeout( + const throttleTimeout = setTimeout( () => this.#nextMeasurement(), this.#throttleMs ); + this.#currentAbortController.signal.addEventListener('abort', () => + clearTimeout(throttleTimeout) + ); } else { this.#nextMeasurement(); } }) .catch(error => { - if (curPromise._cancel) return; + if (this.#currentAbortController.signal.aborted) { + return; + } console.warn(`Error fetching ${url}: ${error}`); if (this.#retries++ < MAX_RETRIES) { @@ -364,12 +389,11 @@ class BandwidthMeasurementEngine { `Connection failed to ${url}. Gave up after ${MAX_RETRIES} retries.` ); } - })); + }); } #cancelCurrentMeasurement() { - const curPromise = this.#currentFetchPromise; - curPromise && (curPromise._cancel = true); + this.#currentAbortController.abort(); } } diff --git a/src/index.d.ts b/src/index.d.ts index b7eecf75..3d9c0bc3 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -32,6 +32,7 @@ export interface ConfigOptions { measureUploadLoadedLatency?: boolean, loadedLatencyThrottle?: number, bandwidthFinishRequestDuration?: number, + bandwidthAbortRequestDuration?: number, estimatedServerTime?: number; // Result interpretation diff --git a/src/index.js b/src/index.js index df727a42..7d2f550b 100644 --- a/src/index.js +++ b/src/index.js @@ -318,6 +318,8 @@ class MeasurementEngine { engine.fetchOptions = { credentials: this.#config.includeCredentials ? 'include' : undefined }; + engine.abortRequestDuration = + this.#config.bandwidthAbortRequestDuration; engine.onMeasurementResult = engine.onNewMeasurementStarted = ( meas, @@ -372,6 +374,8 @@ class MeasurementEngine { }; engine.finishRequestDuration = this.#config.bandwidthFinishRequestDuration; + engine.abortRequestDuration = + this.#config.bandwidthAbortRequestDuration; engine.onNewMeasurementStarted = ({ count, bytes }) => { const res = (msmResults.results = Object.assign(