mirror of https://github.com/ErsatzTV/ErsatzTV.git
11 changed files with 524 additions and 200 deletions
@ -0,0 +1,149 @@ |
|||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||||
|
using NSubstitute; |
||||||
|
using NUnit.Framework; |
||||||
|
using Shouldly; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.FFmpeg; |
||||||
|
|
||||||
|
[TestFixture] |
||||||
|
public class FFmpegSegmenterServiceTests |
||||||
|
{ |
||||||
|
[SetUp] |
||||||
|
public void SetUp() => _service = new FFmpegSegmenterService(NullLogger<FFmpegSegmenterService>.Instance); |
||||||
|
|
||||||
|
private FFmpegSegmenterService _service; |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void TryAddWorker_Should_Fail_For_Second_Worker_On_Same_Channel() |
||||||
|
{ |
||||||
|
IHlsSessionWorker first = Substitute.For<IHlsSessionWorker>(); |
||||||
|
IHlsSessionWorker second = Substitute.For<IHlsSessionWorker>(); |
||||||
|
|
||||||
|
_service.TryAddWorker("1", first).ShouldBeTrue(); |
||||||
|
_service.TryAddWorker("1", second).ShouldBeFalse(); |
||||||
|
|
||||||
|
_service.TryGetWorker("1", out IHlsSessionWorker actual).ShouldBeTrue(); |
||||||
|
actual.ShouldBeSameAs(first); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void TryAddWorker_Should_Throw_For_Null_Worker() => |
||||||
|
Should.Throw<ArgumentNullException>(() => _service.TryAddWorker("1", null)); |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void TryAddWorker_Should_Not_Mark_Channel_Active_When_It_Throws() |
||||||
|
{ |
||||||
|
Should.Throw<ArgumentNullException>(() => _service.TryAddWorker("1", null)); |
||||||
|
|
||||||
|
_service.IsActive("1").ShouldBeFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void RemoveWorker_Should_Ignore_A_Worker_That_Is_Not_Registered() |
||||||
|
{ |
||||||
|
IHlsSessionWorker current = Substitute.For<IHlsSessionWorker>(); |
||||||
|
IHlsSessionWorker stale = Substitute.For<IHlsSessionWorker>(); |
||||||
|
|
||||||
|
_service.TryAddWorker("1", current).ShouldBeTrue(); |
||||||
|
_service.RemoveWorker("1", stale); |
||||||
|
|
||||||
|
_service.IsActive("1").ShouldBeTrue(); |
||||||
|
_service.TryGetWorker("1", out IHlsSessionWorker actual).ShouldBeTrue(); |
||||||
|
actual.ShouldBeSameAs(current); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void RemoveWorker_Should_Remove_Its_Own_Worker() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = Substitute.For<IHlsSessionWorker>(); |
||||||
|
|
||||||
|
_service.TryAddWorker("1", worker).ShouldBeTrue(); |
||||||
|
_service.RemoveWorker("1", worker); |
||||||
|
|
||||||
|
_service.IsActive("1").ShouldBeFalse(); |
||||||
|
_service.Workers.ShouldBeEmpty(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void OnWorkersChanged_Should_Only_Fire_For_Effective_Changes() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = Substitute.For<IHlsSessionWorker>(); |
||||||
|
IHlsSessionWorker other = Substitute.For<IHlsSessionWorker>(); |
||||||
|
|
||||||
|
var count = 0; |
||||||
|
_service.OnWorkersChanged += (_, _) => count++; |
||||||
|
|
||||||
|
_service.TryAddWorker("1", worker); |
||||||
|
count.ShouldBe(1); |
||||||
|
|
||||||
|
_service.TryAddWorker("1", other); |
||||||
|
count.ShouldBe(1); |
||||||
|
|
||||||
|
_service.RemoveWorker("1", other); |
||||||
|
count.ShouldBe(1); |
||||||
|
|
||||||
|
_service.RemoveWorker("1", worker); |
||||||
|
count.ShouldBe(2); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task LockForStart_Should_Block_A_Second_Start_On_The_Same_Channel() |
||||||
|
{ |
||||||
|
IDisposable first = await _service.LockForStart("1", CancellationToken.None); |
||||||
|
|
||||||
|
Task<IDisposable> second = _service.LockForStart("1", CancellationToken.None); |
||||||
|
(await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(250)))).ShouldNotBe(second); |
||||||
|
|
||||||
|
first.Dispose(); |
||||||
|
|
||||||
|
IDisposable acquired = await second.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
acquired.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task LockForStart_Should_Not_Block_A_Start_On_Another_Channel() |
||||||
|
{ |
||||||
|
IDisposable first = await _service.LockForStart("1", CancellationToken.None); |
||||||
|
|
||||||
|
IDisposable second = await _service.LockForStart("2", CancellationToken.None) |
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
|
||||||
|
second.Dispose(); |
||||||
|
first.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task LockForStart_Should_Release_When_The_Releaser_Is_Disposed_Twice() |
||||||
|
{ |
||||||
|
IDisposable first = await _service.LockForStart("1", CancellationToken.None); |
||||||
|
first.Dispose(); |
||||||
|
first.Dispose(); |
||||||
|
|
||||||
|
IDisposable second = await _service.LockForStart("1", CancellationToken.None) |
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
|
||||||
|
// a double dispose must not leave 2 permits behind
|
||||||
|
Task<IDisposable> third = _service.LockForStart("1", CancellationToken.None); |
||||||
|
(await Task.WhenAny(third, Task.Delay(TimeSpan.FromMilliseconds(250)))).ShouldNotBe(third); |
||||||
|
|
||||||
|
second.Dispose(); |
||||||
|
(await third.WaitAsync(TimeSpan.FromSeconds(5))).Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task LockForStart_Should_Throw_When_The_Token_Is_Already_Cancelled() |
||||||
|
{ |
||||||
|
IDisposable first = await _service.LockForStart("1", CancellationToken.None); |
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(); |
||||||
|
await cts.CancelAsync(); |
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => _service.LockForStart("1", cts.Token)); |
||||||
|
|
||||||
|
// the cancelled waiter must not have taken the lock
|
||||||
|
first.Dispose(); |
||||||
|
(await _service.LockForStart("1", CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5))).Dispose(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using NSubstitute; |
||||||
|
using NUnit.Framework; |
||||||
|
using Shouldly; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.FFmpeg; |
||||||
|
|
||||||
|
[TestFixture] |
||||||
|
public class SessionStartWaitTests |
||||||
|
{ |
||||||
|
private static readonly TimeSpan ShortDeadline = TimeSpan.FromMilliseconds(250); |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task ForReady_Should_Succeed_When_The_Playlist_Becomes_Ready() |
||||||
|
{ |
||||||
|
var wait = new TaskCompletionSource(); |
||||||
|
IHlsSessionWorker worker = WorkerWaiting(wait.Task, out _); |
||||||
|
|
||||||
|
var run = new TaskCompletionSource(); |
||||||
|
Task<Either<BaseError, Unit>> ready = SessionStartWait.ForReady( |
||||||
|
"1", |
||||||
|
worker, |
||||||
|
run.Task, |
||||||
|
1, |
||||||
|
TimeSpan.FromSeconds(30), |
||||||
|
CancellationToken.None); |
||||||
|
|
||||||
|
wait.SetResult(); |
||||||
|
|
||||||
|
(await ready.WaitAsync(TimeSpan.FromSeconds(5))).IsRight.ShouldBeTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task ForReady_Should_Fail_When_The_Run_Task_Ends_First() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = WorkerWaiting(NeverReady(), out _); |
||||||
|
|
||||||
|
var run = new TaskCompletionSource(); |
||||||
|
Task<Either<BaseError, Unit>> ready = SessionStartWait.ForReady( |
||||||
|
"1", |
||||||
|
worker, |
||||||
|
run.Task, |
||||||
|
1, |
||||||
|
TimeSpan.FromSeconds(30), |
||||||
|
CancellationToken.None); |
||||||
|
|
||||||
|
run.SetResult(); |
||||||
|
|
||||||
|
Either<BaseError, Unit> result = await ready.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
ErrorMessage(result).ShouldContain("ended before the playlist was ready"); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task ForReady_Should_Stop_The_Wait_When_The_Run_Task_Ends_First() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = WorkerWaiting(NeverReady(), out Func<CancellationToken> waitToken); |
||||||
|
|
||||||
|
var run = new TaskCompletionSource(); |
||||||
|
Task<Either<BaseError, Unit>> ready = SessionStartWait.ForReady( |
||||||
|
"1", |
||||||
|
worker, |
||||||
|
run.Task, |
||||||
|
1, |
||||||
|
TimeSpan.FromSeconds(30), |
||||||
|
CancellationToken.None); |
||||||
|
|
||||||
|
run.SetResult(); |
||||||
|
await ready.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
|
||||||
|
// an abandoned wait polls on a timer, so it has to be cancelled, not just left behind
|
||||||
|
waitToken().IsCancellationRequested.ShouldBeTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task ForReady_Should_Fail_At_The_Deadline_When_The_Playlist_Never_Appears() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = WorkerWaiting(NeverReady(), out _); |
||||||
|
|
||||||
|
Either<BaseError, Unit> result = await SessionStartWait.ForReady( |
||||||
|
"1", |
||||||
|
worker, |
||||||
|
NeverReady(), |
||||||
|
1, |
||||||
|
ShortDeadline, |
||||||
|
CancellationToken.None) |
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5)); |
||||||
|
|
||||||
|
ErrorMessage(result).ShouldContain("did not become ready"); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task ForReady_Should_Propagate_Cancellation_From_The_Request() |
||||||
|
{ |
||||||
|
IHlsSessionWorker worker = WorkerWaiting(NeverReady(), out _); |
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(); |
||||||
|
Task<Either<BaseError, Unit>> ready = SessionStartWait.ForReady( |
||||||
|
"1", |
||||||
|
worker, |
||||||
|
NeverReady(), |
||||||
|
1, |
||||||
|
TimeSpan.FromSeconds(30), |
||||||
|
cts.Token); |
||||||
|
|
||||||
|
await cts.CancelAsync(); |
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => ready.WaitAsync(TimeSpan.FromSeconds(5))); |
||||||
|
} |
||||||
|
|
||||||
|
private static Task NeverReady() => new TaskCompletionSource().Task; |
||||||
|
|
||||||
|
private static string ErrorMessage(Either<BaseError, Unit> result) => |
||||||
|
result.Match(_ => string.Empty, error => error.Value); |
||||||
|
|
||||||
|
// returns a worker whose wait completes with `wait`, plus an accessor for the token it was given
|
||||||
|
private static IHlsSessionWorker WorkerWaiting(Task wait, out Func<CancellationToken> waitToken) |
||||||
|
{ |
||||||
|
CancellationToken captured = CancellationToken.None; |
||||||
|
waitToken = () => captured; |
||||||
|
|
||||||
|
IHlsSessionWorker worker = Substitute.For<IHlsSessionWorker>(); |
||||||
|
worker.WaitForPlaylistSegments(Arg.Any<int>(), Arg.Any<CancellationToken>()) |
||||||
|
.Returns(callInfo => |
||||||
|
{ |
||||||
|
captured = callInfo.Arg<CancellationToken>(); |
||||||
|
return wait.WaitAsync(captured); |
||||||
|
}); |
||||||
|
|
||||||
|
return worker; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.FFmpeg; |
||||||
|
|
||||||
|
public static class SessionStartWait |
||||||
|
{ |
||||||
|
public static async Task<Either<BaseError, Unit>> ForReady( |
||||||
|
string channelNumber, |
||||||
|
IHlsSessionWorker worker, |
||||||
|
Task runTask, |
||||||
|
int initialSegmentCount, |
||||||
|
TimeSpan startDeadline, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
||||||
|
timeout.CancelAfter(startDeadline); |
||||||
|
|
||||||
|
Task waitTask = worker.WaitForPlaylistSegments(initialSegmentCount, timeout.Token); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
Task first = await Task.WhenAny(waitTask, runTask); |
||||||
|
if (first == runTask) |
||||||
|
{ |
||||||
|
return BaseError.New($"Session for channel {channelNumber} ended before the playlist was ready"); |
||||||
|
} |
||||||
|
|
||||||
|
await waitTask; |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) |
||||||
|
{ |
||||||
|
return BaseError.New($"Session for channel {channelNumber} did not become ready in {startDeadline}"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
// the wait polls on a timer; disposing the token source does not stop it, so an
|
||||||
|
// abandoned wait would poll for the life of the process
|
||||||
|
await timeout.CancelAsync(); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
await waitTask; |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
// the start already has its result
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
namespace ErsatzTV.Core.FFmpeg; |
||||||
|
|
||||||
|
public sealed class StartLockReleaser(SemaphoreSlim slim) : IDisposable |
||||||
|
{ |
||||||
|
private bool _disposedValue; |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
Dispose(true); |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
|
||||||
|
private void Dispose(bool disposing) |
||||||
|
{ |
||||||
|
if (!_disposedValue) |
||||||
|
{ |
||||||
|
if (disposing) |
||||||
|
{ |
||||||
|
slim.Release(); |
||||||
|
} |
||||||
|
|
||||||
|
_disposedValue = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue