Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,8 @@ export enum ErrorDetails {
// (undocumented)
MANIFEST_PARSING_ERROR = "manifestParsingError",
// (undocumented)
MEDIA_SOURCE_REQUIRES_RESET = "mediaSourceRequiresReset",
// (undocumented)
REMUX_ALLOC_ERROR = "remuxAllocError",
// (undocumented)
SUBTITLE_LOAD_ERROR = "subtitleTrackLoadError",
Expand Down Expand Up @@ -4200,9 +4202,11 @@ export const enum NetworkErrorAction {
// (undocumented)
RemoveAlternatePermanently = 3,
// (undocumented)
ResetMediaSource = 6,// Reserved for future use
// (undocumented)
RetryRequest = 5,// Reserved for future use
// (undocumented)
SendAlternateToPenaltyBox = 2,// Reserved for future use
SendAlternateToPenaltyBox = 2,
// (undocumented)
SendEndCallback = 1
}
Expand Down
31 changes: 28 additions & 3 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import BufferOperationQueue from './buffer-operation-queue';
import { createDoNothingErrorAction } from './error-controller';
import {
createDoNothingErrorAction,
ErrorActionFlags,
NetworkErrorAction,
} from './error-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { ElementaryStreamTypes } from '../loader/fragment';
Expand Down Expand Up @@ -1584,7 +1588,16 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
this.warn(
'MediaSource closed while media attached - triggering recovery',
);
this.hls.recoverMediaError();
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
fatal: false,
error: new Error('MediaSource closed while media is still attached'),
errorAction: {
action: NetworkErrorAction.ResetMediaSource,
flags: ErrorActionFlags.None,
},
});
}
};

Expand Down Expand Up @@ -1636,7 +1649,19 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
this.warn(
`MediaSource readyState "ended" during SourceBuffer error - triggering recovery`,
);
this.hls.recoverMediaError();
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
fatal: false,
sourceBufferName: type,
error: new Error(
`${type} SourceBuffer error. MediaSource readyState: ended`,
),
errorAction: {
action: NetworkErrorAction.ResetMediaSource,
flags: ErrorActionFlags.None,
},
});
return;
}

Expand Down
4 changes: 4 additions & 0 deletions src/controller/error-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const enum NetworkErrorAction {
RemoveAlternatePermanently = 3, // Reserved for future use
InsertDiscontinuity = 4, // Reserved for future use
RetryRequest = 5,
ResetMediaSource = 6,
}

export const enum ErrorActionFlags {
Expand Down Expand Up @@ -480,6 +481,9 @@ export default class ErrorController
case NetworkErrorAction.RetryRequest:
// handled by stream and playlist/level controllers
break;
case NetworkErrorAction.ResetMediaSource:
this.hls.recoverMediaError();
break;
}

if (data.fatal) {
Expand Down
2 changes: 2 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export enum ErrorDetails {
INTERNAL_ABORTED = 'aborted',
// Triggered when attachMedia fails
ATTACH_MEDIA_ERROR = 'attachMediaError',
// Triggered when MediaSource enters an invalid state that requires a reset
MEDIA_SOURCE_REQUIRES_RESET = 'mediaSourceRequiresReset',
// Uncategorized error
UNKNOWN = 'unknown',
}
26 changes: 19 additions & 7 deletions tests/unit/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import BufferController from '../../../src/controller/buffer-controller';
import { NetworkErrorAction } from '../../../src/controller/error-controller';
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { MockMediaElement, MockMediaSource } from '../utils/mock-media';
Expand Down Expand Up @@ -559,24 +561,34 @@ describe('BufferController', function () {
});
});

describe('Safari MediaSource bfcache close recovery', function () {
it('triggers recoverMediaError when sourceclose fires with media attached', function () {
describe('MediaSource requires reset', function () {
it('emits error when sourceclose fires with media attached', function () {
const media = new MockMediaElement() as unknown as HTMLMediaElement;
const mediaSource = new MockMediaSource() as unknown as MediaSource;
const recoverMediaErrorSpy = sandbox.spy(hls, 'recoverMediaError');
const triggerSpy = sandbox.spy(hls, 'trigger');
bufferController.media = media;
bufferController.mediaSource = mediaSource;
bufferController._onMediaSourceClose();
expect(recoverMediaErrorSpy).to.have.been.calledOnce;
expect(triggerSpy).to.have.been.calledWith(
Events.ERROR,
sinon.match({
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
fatal: false,
errorAction: sinon.match({
action: NetworkErrorAction.ResetMediaSource,
}),
}),
);
});

it('does not trigger recovery when sourceclose fires without media attached', function () {
it('does not emit error when media is not attached', function () {
const mediaSource = new MockMediaSource() as unknown as MediaSource;
const recoverMediaErrorSpy = sandbox.spy(hls, 'recoverMediaError');
const triggerSpy = sandbox.spy(hls, 'trigger');
bufferController.media = null;
bufferController.mediaSource = mediaSource;
bufferController._onMediaSourceClose();
expect(recoverMediaErrorSpy).to.not.have.been.called;
expect(triggerSpy).to.not.have.been.calledWith(Events.ERROR);
});
});
});
24 changes: 24 additions & 0 deletions tests/unit/controller/error-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { fakeServer } from 'nise';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { multivariantPlaylistWithRedundantFallbacks } from './level-controller';
import {
ErrorActionFlags,
NetworkErrorAction,
} from '../../../src/controller/error-controller';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
Expand Down Expand Up @@ -545,6 +549,26 @@ segment.mp4
});
});

describe('Media Error Handling', function () {
it('treats MEDIA_SOURCE_REQUIRES_RESET as recoverable and calls recoverMediaError', function () {
const recoverSpy = sinon.spy(hls, 'recoverMediaError');
const data: any = {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
fatal: false,
error: new Error(
'MediaSource requires reset while media is still attached',
),
errorAction: {
action: NetworkErrorAction.ResetMediaSource,
flags: ErrorActionFlags.None,
},
};
hls.trigger(Events.ERROR, data);
expect(recoverSpy).to.have.been.calledOnce;
});
});

describe('Transmuxer Error Handling', function () {
it('Fragment parsing errors are fatal with no alternates after retries', function () {
server.respondWith('oneSegmentVod-mp2ts.m3u8/segment.ts', [
Expand Down
Loading