Skip to content

Commit

Permalink
Implement ErrorController
Browse files Browse the repository at this point in the history
Add Error Handling Integration Tests
Handle missed probe transmux probe with with FRAG_PARSING_ERROR
Handle encrypted init segment and subtitle decryption failure with FRAG_DECRYPT_ERROR
Remove delay when retrying after timeout
Trigger FRAG_LOADING after request is made
ERROR event data type must include an error property
Remove use of `console.warn` from mp4-tools
Add missing TypeScript type exports
  • Loading branch information
robwalch committed Feb 14, 2023
1 parent 3e578db commit cc87b72
Show file tree
Hide file tree
Showing 33 changed files with 2,061 additions and 439 deletions.
704 changes: 685 additions & 19 deletions api-extractor/report/hls.js.api.md

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import EMEController, {
} from './controller/eme-controller';
import CMCDController from './controller/cmcd-controller';
import ContentSteeringController from './controller/content-steering-controller';
import ErrorController from './controller/error-controller';
import XhrLoader from './utils/xhr-loader';
import FetchLoader, { fetchSupported } from './utils/fetch-loader';
import Cues from './utils/cues';
Expand Down Expand Up @@ -235,7 +236,7 @@ export type HlsConfig = {
cmcdController?: typeof CMCDController;
// Content Steering
contentSteeringController?: typeof ContentSteeringController;

errorController: typeof ErrorController;
abrController: typeof AbrController;
bufferController: typeof BufferController;
capLevelController: typeof CapLevelController;
Expand Down Expand Up @@ -299,13 +300,13 @@ export const hlsDefaultConfig: HlsConfig = {
manifestLoadingMaxRetryTimeout: 64000, // used by playlist-loader
startLevel: undefined, // used by level-controller
levelLoadingTimeOut: 10000, // used by playlist-loader
levelLoadingMaxRetry: 4, // used by playlist-loader
levelLoadingRetryDelay: 1000, // used by playlist-loader
levelLoadingMaxRetryTimeout: 64000, // used by playlist-loader
levelLoadingMaxRetry: 4, // used by playlist/track controllers
levelLoadingRetryDelay: 1000, // used by playlist/track controllers
levelLoadingMaxRetryTimeout: 64000, // used by playlist/track controllers
fragLoadingTimeOut: 20000, // used by fragment-loader
fragLoadingMaxRetry: 6, // used by fragment-loader
fragLoadingRetryDelay: 1000, // used by fragment-loader
fragLoadingMaxRetryTimeout: 64000, // used by fragment-loader
fragLoadingMaxRetry: 6, // used by stream controllers
fragLoadingRetryDelay: 1000, // used by stream controllers
fragLoadingMaxRetryTimeout: 64000, // used by stream controllers
startFragPrefetch: false, // used by stream-controller
fpsDroppedMonitoringPeriod: 5000, // used by fps-controller
fpsDroppedMonitoringThreshold: 0.2, // used by fps-controller
Expand Down Expand Up @@ -366,6 +367,7 @@ export const hlsDefaultConfig: HlsConfig = {
contentSteeringController: __USE_CONTENT_STEERING__
? ContentSteeringController
: undefined,
errorController: ErrorController,
};

function timelineConfig(): TimelineControllerConfig {
Expand Down
7 changes: 5 additions & 2 deletions src/controller/audio-track-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ class AudioTrackController extends BasePlaylistController {
}

protected onError(event: Events.ERROR, data: ErrorData): void {
super.onError(event, data);
if (data.fatal || !data.context) {
return;
}
Expand Down Expand Up @@ -217,12 +216,16 @@ class AudioTrackController extends BasePlaylistController {
if (trackId !== -1) {
this.setAudioTrack(trackId);
} else {
this.warn(`No track found for running audio group-ID: ${this.groupId}`);
const error = new Error(
`No track found for running audio group-ID: ${this.groupId}`
);
this.warn(error.message);

this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
fatal: true,
error,
});
}
}
Expand Down
18 changes: 6 additions & 12 deletions src/controller/base-playlist-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
} from '../types/events';
import { ErrorData } from '../types/events';
import { Events } from '../events';
import { ErrorTypes } from '../errors';
import { ErrorDetails, ErrorTypes } from '../errors';

export default class BasePlaylistController implements NetworkComponentAPI {
protected hls: Hls;
Expand All @@ -35,16 +35,6 @@ export default class BasePlaylistController implements NetworkComponentAPI {
this.hls = this.log = this.warn = null;
}

protected onError(event: Events.ERROR, data: ErrorData): void {
if (
data.fatal &&
(data.type === ErrorTypes.NETWORK_ERROR ||
data.type === ErrorTypes.KEY_SYSTEM_ERROR)
) {
this.stopLoad();
}
}

protected clearTimer(): void {
clearTimeout(this.timer);
this.timer = -1;
Expand Down Expand Up @@ -318,7 +308,11 @@ export default class BasePlaylistController implements NetworkComponentAPI {
// exponential backoff capped to max retry timeout
const delay = Math.min(
Math.pow(2, this.retryCount) * config.levelLoadingRetryDelay,
config.levelLoadingMaxRetryTimeout
errorEvent.details === ErrorDetails.LEVEL_LOAD_TIMEOUT ||
errorEvent.details === ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT ||
errorEvent.details === ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT
? 0
: config.levelLoadingMaxRetryTimeout
);
// Schedule level/track reload
this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
Expand Down
108 changes: 63 additions & 45 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,24 @@ export default class BaseStreamController
decryptData.method === 'AES-128'
) {
const startTime = self.performance.now();
// decrypt the subtitles
// decrypt init segment data
return this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer
)
.catch((err) => {
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
});
throw err;
})
.then((decryptedData) => {
const endTime = self.performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
Expand Down Expand Up @@ -624,14 +635,9 @@ export default class BaseStreamController
);
this.nextLoadPosition = part.start + part.duration;
this.state = State.FRAG_LOADING;
this.hls.trigger(Events.FRAG_LOADING, {
frag,
part,
targetBufferTime,
});
this.throwIfFragContextChanged('FRAG_LOADING parts');
let result: Promise<PartsLoadedData | FragLoadedData | null>;
if (keyLoadingPromise) {
return keyLoadingPromise
result = keyLoadingPromise
.then((keyLoadedData) => {
if (
!keyLoadedData ||
Expand All @@ -647,14 +653,21 @@ export default class BaseStreamController
);
})
.catch((error) => this.handleFragLoadError(error));
} else {
result = this.doFragPartsLoad(
frag,
part,
level,
progressCallback
).catch((error: LoadError) => this.handleFragLoadError(error));
}

return this.doFragPartsLoad(
this.hls.trigger(Events.FRAG_LOADING, {
frag,
part,
level,
progressCallback
).catch((error: LoadError) => this.handleFragLoadError(error));
targetBufferTime,
});
this.throwIfFragContextChanged('FRAG_LOADING parts');
return result;
} else if (
!frag.url ||
this.loadedEndOfParts(partList, targetBufferTime)
Expand All @@ -677,38 +690,40 @@ export default class BaseStreamController
this.nextLoadPosition = frag.start + frag.duration;
}
this.state = State.FRAG_LOADING;
this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });
this.throwIfFragContextChanged('FRAG_LOADING');

// Load key before streaming fragment data
const dataOnProgress = this.config.progressive;
let result: Promise<PartsLoadedData | FragLoadedData | null>;
if (dataOnProgress && keyLoadingPromise) {
return keyLoadingPromise
result = keyLoadingPromise
.then((keyLoadedData) => {
if (!keyLoadedData || this.fragContextChanged(keyLoadedData?.frag)) {
return null;
}
return this.fragmentLoader.load(frag, progressCallback);
})
.catch((error) => this.handleFragLoadError(error));
} else {
// load unencrypted fragment data with progress event,
// or handle fragment result after key and fragment are finished loading
result = Promise.all([
this.fragmentLoader.load(
frag,
dataOnProgress ? progressCallback : undefined
),
keyLoadingPromise,
])
.then(([fragLoadedData]) => {
if (!dataOnProgress && fragLoadedData && progressCallback) {
progressCallback(fragLoadedData);
}
return fragLoadedData;
})
.catch((error) => this.handleFragLoadError(error));
}

// load unencrypted fragment data with progress event,
// or handle fragment result after key and fragment are finished loading
return Promise.all([
this.fragmentLoader.load(
frag,
dataOnProgress ? progressCallback : undefined
),
keyLoadingPromise,
])
.then(([fragLoadedData]) => {
if (!dataOnProgress && fragLoadedData && progressCallback) {
progressCallback(fragLoadedData);
}
return fragLoadedData;
})
.catch((error) => this.handleFragLoadError(error));
this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });
this.throwIfFragContextChanged('FRAG_LOADING');
return result;
}

private throwIfFragContextChanged(context: string): void | never {
Expand Down Expand Up @@ -768,6 +783,7 @@ export default class BaseStreamController
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.INTERNAL_EXCEPTION,
err: error,
error,
fatal: true,
});
}
Expand Down Expand Up @@ -1360,14 +1376,11 @@ export default class BaseStreamController
if (!frag || frag.type !== filterType) {
return;
}
const fragCurrent = this.fragCurrent;
console.assert(
fragCurrent &&
frag.sn === fragCurrent.sn &&
frag.level === fragCurrent.level &&
frag.urlId === fragCurrent.urlId,
'Frag load error must match current frag to retry'
);
if (this.fragContextChanged(frag)) {
this.warn('Frag load error must match current frag to retry');
return;
}

// keep retrying until the limit will be reached
if (this.fragLoadError + 1 <= config.fragLoadingMaxRetry) {
if (!this.loadedmetadata) {
Expand All @@ -1377,13 +1390,16 @@ export default class BaseStreamController
// exponential backoff capped to config.fragLoadingMaxRetryTimeout
const delay = Math.min(
Math.pow(2, this.fragLoadError) * config.fragLoadingRetryDelay,
config.fragLoadingMaxRetryTimeout
data.details === ErrorDetails.FRAG_LOAD_TIMEOUT ||
data.details === ErrorDetails.KEY_LOAD_TIMEOUT
? 0
: config.fragLoadingMaxRetryTimeout
);
this.fragLoadError++;
this.warn(
`Fragment ${frag.sn} of ${filterType} ${frag.level} failed to load, retrying in ${delay}ms`
`Fragment ${frag.sn} of ${filterType} ${frag.level} errored with ${data.details}, retrying ${this.fragLoadError}/${config.fragLoadingMaxRetry} in ${delay}ms`
);
this.retryDate = self.performance.now() + delay;
this.fragLoadError++;
this.state = State.FRAG_LOADING_WAITING_RETRY;
} else if (data.levelRetry) {
if (filterType === PlaylistLevelType.AUDIO) {
Expand All @@ -1393,14 +1409,16 @@ export default class BaseStreamController
// Fragment errors that result in a level switch or redundant fail-over
// should reset the stream controller state to idle
this.fragLoadError = 0;
if (!this.loadedmetadata) {
this.startFragRequested = false;
}
this.state = State.IDLE;
} else {
logger.error(
`${data.details} reaches max retry, redispatch as fatal ...`
);
// switch error to fatal
data.fatal = true;
this.hls.stopLoad();
this.state = State.ERROR;
}
}
Expand Down
28 changes: 15 additions & 13 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export default class BufferController implements ComponentAPI {
parent: frag.type,
details: ErrorDetails.BUFFER_APPEND_ERROR,
err,
error: err,
fatal: false,
};

Expand All @@ -433,7 +434,6 @@ export default class BufferController implements ComponentAPI {
`[buffer-controller]: Failed ${hls.config.appendErrorMaxRetry} times to append segment in sourceBuffer`
);
event.fatal = true;
hls.stopLoad();
}
}
hls.trigger(Events.ERROR, event);
Expand Down Expand Up @@ -717,18 +717,23 @@ export default class BufferController implements ComponentAPI {
this.pendingTracks = {};
// append any pending segments now !
const buffers = this.getSourceBufferTypes();
if (buffers.length === 0) {
if (buffers.length) {
this.hls.trigger(Events.BUFFER_CREATED, { tracks: this.tracks });
buffers.forEach((type: SourceBufferName) => {
operationQueue.executeNext(type);
});
} else {
const error = new Error(
'could not create source buffer for media codec(s)'
);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
fatal: true,
reason: 'could not create source buffer for media codec(s)',
error,
reason: error.message,
});
return;
}
buffers.forEach((type: SourceBufferName) => {
operationQueue.executeNext(type);
});
}
}

Expand All @@ -737,7 +742,6 @@ export default class BufferController implements ComponentAPI {
if (!mediaSource) {
throw Error('createSourceBuffers called when mediaSource was null');
}
let tracksCreated = 0;
for (const trackName in tracks) {
if (!sourceBuffer[trackName]) {
const track = tracks[trackName as keyof TrackSet];
Expand Down Expand Up @@ -765,7 +769,6 @@ export default class BufferController implements ComponentAPI {
metadata: track.metadata,
id: track.id,
};
tracksCreated++;
} catch (err) {
logger.error(
`[buffer-controller]: error while trying to add sourceBuffer: ${err.message}`
Expand All @@ -780,9 +783,6 @@ export default class BufferController implements ComponentAPI {
}
}
}
if (tracksCreated) {
this.hls.trigger(Events.BUFFER_CREATED, { tracks: this.tracks });
}
}

// Keep as arrow functions so that we can directly reference these functions directly as event listeners
Expand Down Expand Up @@ -833,12 +833,14 @@ export default class BufferController implements ComponentAPI {
}

private _onSBUpdateError(type: SourceBufferName, event: Event) {
logger.error(`[buffer-controller]: ${type} SourceBuffer error`, event);
const error = new Error(`${type} SourceBuffer error`);
logger.error(`[buffer-controller]: ${error}`, event);
// according to http://www.w3.org/TR/media-source/#sourcebuffer-append-error
// SourceBuffer errors are not necessarily fatal; if so, the HTMLMediaElement will fire an error event
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_APPENDING_ERROR,
error,
fatal: false,
});
// updateend is always fired after error, so we'll allow that to shift the current operation off of the queue
Expand Down
Loading

0 comments on commit cc87b72

Please sign in to comment.