mirror of https://github.com/ErsatzTV/ErsatzTV.git
11 changed files with 524 additions and 200 deletions
@ -0,0 +1,149 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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