Skip to content

Commit 6373b33

Browse files
Re-elect penalized level after configurable penalty expiry (#7771)
Authored-by: Harshit Dokania <hadokani@microsoft.com>
1 parent 2481765 commit 6373b33

7 files changed

Lines changed: 57 additions & 5 deletions

File tree

api-extractor/report/hls.js.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2355,6 +2355,7 @@ export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming {
23552355
executeStart: number;
23562356
}
23572357

2358+
// Warning: (ae-forgotten-export) The symbol "ErrorControllerConfig" needs to be exported by the entry point hls.d.ts
23582359
// Warning: (ae-missing-release-tag) "HlsConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
23592360
//
23602361
// @public (undocumented)
@@ -2400,7 +2401,7 @@ export type HlsConfig = {
24002401
progressive: boolean;
24012402
lowLatencyMode: boolean;
24022403
primarySessionId?: string;
2403-
} & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & FPSControllerConfig & GapControllerConfig & LevelControllerConfig & MP4RemuxerConfig & StreamControllerConfig & SelectionPreferences & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig & HlsLoadPolicies & PlaylistControllerConfig & FragmentLoaderConfig & PlaylistLoaderConfig;
2404+
} & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & ErrorControllerConfig & FPSControllerConfig & GapControllerConfig & LevelControllerConfig & MP4RemuxerConfig & StreamControllerConfig & SelectionPreferences & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig & HlsLoadPolicies & PlaylistControllerConfig & FragmentLoaderConfig & PlaylistLoaderConfig;
24042405

24052406
// Warning: (ae-missing-release-tag) "HlsEventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
24062407
//
@@ -3251,6 +3252,8 @@ export class Level {
32513252
// (undocumented)
32523253
loadError: number;
32533254
// (undocumented)
3255+
loadErrorTime: number;
3256+
// (undocumented)
32543257
get maxBitrate(): number;
32553258
// (undocumented)
32563259
readonly name: string;

src/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ export type FPSControllerConfig = {
153153
fpsDroppedMonitoringThreshold: number;
154154
};
155155

156+
export type ErrorControllerConfig = {
157+
/**
158+
* Duration in milliseconds after which a penalized level (loadError > 0) becomes
159+
* eligible for re-election. Set to 0 (default) to disable penalty expiry.
160+
*/
161+
errorPenaltyExpireMs: number;
162+
};
163+
156164
export type LevelControllerConfig = {
157165
startLevel?: number;
158166
};
@@ -350,6 +358,7 @@ export type HlsConfig = {
350358
BufferControllerConfig &
351359
CapLevelControllerConfig &
352360
EMEControllerConfig &
361+
ErrorControllerConfig &
353362
FPSControllerConfig &
354363
GapControllerConfig &
355364
LevelControllerConfig &
@@ -478,6 +487,7 @@ export const hlsDefaultConfig: HlsConfig = {
478487
interstitialLiveLookAhead: 10,
479488
useMediaCapabilities: __USE_MEDIA_CAPABILITIES__,
480489
preserveManualLevelOnError: false,
490+
errorPenaltyExpireMs: 0, // used by error-controller and abr-controller
481491

482492
certLoadPolicy: {
483493
default: defaultLoadPolicy,

src/controller/abr-controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ErrorDetails } from '../errors';
22
import { Events } from '../events';
33
import { type Fragment, fragmentsAreEqual } from '../loader/fragment';
44
import { PlaylistLevelType } from '../types/loader';
5+
import { isPenaltyExpired } from '../utils/error-helper';
56
import EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator';
67
import { Logger } from '../utils/logger';
78
import {
@@ -959,9 +960,10 @@ class AbrController extends Logger implements AbrComponentAPI {
959960
const canSwitchWithinTolerance =
960961
// if adjusted bw is greater than level bitrate AND
961962
adjustedbw >= bitrate &&
962-
// no level change, or new level has no error history
963+
// no level change, new level has no error history or penalty expired because error happened a while ago
963964
(i === lastLoadedFragLevel ||
964-
(levelInfo.loadError === 0 && levelInfo.fragmentError === 0)) &&
965+
(levelInfo.loadError === 0 && levelInfo.fragmentError === 0) ||
966+
isPenaltyExpired(levelInfo, this.hls.config.errorPenaltyExpireMs)) &&
965967
// fragment fetchDuration unknown OR live stream OR fragment fetchDuration less than max allowed fetch duration, then this level matches
966968
// we don't account for max Fetch Duration for live streams, this is to avoid switching down when near the edge of live sliding window ...
967969
// special case to support startLevel = -1 (bitrateTest) on live streams : in that case we should not exit loop so that findBestLevel will return -1

src/controller/error-controller.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getCodecsForMimeType } from '../utils/codecs';
77
import {
88
getRetryConfig,
99
isKeyError,
10+
isPenaltyExpired,
1011
isTimeoutError,
1112
isUnusableKeyError,
1213
shouldRetry,
@@ -362,6 +363,7 @@ export default class ErrorController
362363
if (level) {
363364
const errorDetails = data.details;
364365
level.loadError++;
366+
level.loadErrorTime = self.performance.now();
365367
if (errorDetails === ErrorDetails.BUFFER_APPEND_ERROR) {
366368
level.fragmentError++;
367369
}
@@ -397,7 +399,11 @@ export default class ErrorController
397399
candidate !== loadLevel &&
398400
candidate >= minAutoLevel &&
399401
candidate <= maxAutoLevel &&
400-
levels[candidate].loadError === 0
402+
(levels[candidate].loadError === 0 ||
403+
isPenaltyExpired(
404+
levels[candidate],
405+
hls.config.errorPenaltyExpireMs,
406+
))
401407
) {
402408
const levelCandidate = levels[candidate];
403409

src/types/level.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export class Level {
120120
public details?: LevelDetails;
121121
public fragmentError: number = 0;
122122
public loadError: number = 0;
123+
public loadErrorTime: number = 0;
123124
public loaded?: { bytes: number; duration: number };
124125
public realBitrate: number = 0;
125126
public supportedPromise?: Promise<MediaDecodingInfo>;

src/utils/error-helper.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ErrorDetails } from '../errors';
22
import type { LoaderConfig, LoadPolicy, RetryConfig } from '../config';
33
import type { ErrorData } from '../types/events';
4+
import type { Level } from '../types/level';
45
import type { LoaderResponse } from '../types/loader';
56

67
export function isTimeoutError(error: ErrorData): boolean {
@@ -93,3 +94,17 @@ export function retryForHttpStatus(httpStatus: number | undefined): boolean {
9394
export function offlineHttpStatus(httpStatus: number | undefined): boolean {
9495
return httpStatus === 0 && navigator.onLine === false;
9596
}
97+
98+
export function isPenaltyExpired(level: Level, expireMs: number): boolean {
99+
// A penalized level (loadError > 0) is excluded from ABR upswitch and error
100+
// recovery candidate selection. Its errors only clear when it successfully
101+
// buffers — but it can't buffer because it won't be selected — leaving the
102+
// player stuck at a lower quality until stopLoad. This allows a penalized
103+
// level to be re-elected once its penalty has expired.
104+
return (
105+
expireMs > 0 &&
106+
level.loadError > 0 &&
107+
level.loadErrorTime !== 0 &&
108+
self.performance.now() - level.loadErrorTime > expireMs
109+
);
110+
}

tests/unit/utils/error-helper.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
import { expect } from 'chai';
2-
import { shouldRetry } from '../../../src/utils/error-helper';
2+
import { isPenaltyExpired, shouldRetry } from '../../../src/utils/error-helper';
33

44
describe('ErrorHelper', function () {
5+
describe('isPenaltyExpired', function () {
6+
it('level with loadError but no loadErrorTime is not elected', function () {
7+
const level = { loadError: 1, loadErrorTime: 0 };
8+
expect(isPenaltyExpired(level, 60_000)).to.be.false;
9+
});
10+
11+
it('level with loadError and elapsed loadErrorTime is elected', function () {
12+
const level = {
13+
loadError: 1,
14+
loadErrorTime: self.performance.now() - 60_001,
15+
};
16+
expect(isPenaltyExpired(level, 60_000)).to.be.true;
17+
});
18+
});
19+
520
it('shouldRetry', function () {
621
const retryConfig = {
722
maxNumRetry: 3,

0 commit comments

Comments
 (0)