Files
meeting-assistant/MeetingAssistant.Tests/PyannoteDiarizationWarmupHostedServiceTests.cs

268 lines
10 KiB
C#

using MeetingAssistant;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Runtime.ExceptionServices;
namespace MeetingAssistant.Tests;
public sealed class PyannoteDiarizationWarmupHostedServiceTests
{
[Fact]
public async Task HostedServiceStopsSafelyWhenShutdownRequestsOverlap()
{
const int iterations = 64;
const int shutdownCallers = 8;
using var applicationLifetime = new CancellationTokenSource();
var finalizer = new PyannoteTranscriptFinalizer(
new BlockingCommandRunner(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
using var callersReady = new Barrier(shutdownCallers + 1);
var shutdownErrors = new ConcurrentQueue<ExceptionDispatchInfo>();
PyannoteDiarizationWarmupHostedService? service = null;
var shutdownTasks = Enumerable.Range(0, shutdownCallers)
.Select(_ => Task.Factory.StartNew(() =>
{
try
{
for (var iteration = 0; iteration < iterations; iteration++)
{
WaitForShutdownCallers();
try
{
service!.StopAsync(CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
}
catch (Exception exception)
{
shutdownErrors.Enqueue(ExceptionDispatchInfo.Capture(exception));
}
WaitForShutdownCallers();
if (!shutdownErrors.IsEmpty)
{
break;
}
}
}
catch (Exception exception)
{
shutdownErrors.Enqueue(ExceptionDispatchInfo.Capture(exception));
}
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default))
.ToArray();
try
{
for (var iteration = 0; iteration < iterations; iteration++)
{
service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions()),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(applicationLifetime.Token);
WaitForShutdownCallers();
WaitForShutdownCallers();
if (!shutdownErrors.IsEmpty)
{
break;
}
}
}
finally
{
await Task.WhenAll(shutdownTasks).WaitAsync(TimeSpan.FromSeconds(10));
}
if (shutdownErrors.TryPeek(out var shutdownError))
{
shutdownError.Throw();
}
void WaitForShutdownCallers()
{
Assert.True(
callersReady.SignalAndWait(TimeSpan.FromSeconds(5)),
"Timed out coordinating overlapping shutdown requests.");
}
}
[Fact]
public async Task HostedServiceWarmsEnabledValidationRuntimeWithoutBlockingStartup()
{
var commandRunner = new BlockingCommandRunner();
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(
CreateValidationOptions("meeting-assistant-pyannote-validation:local")),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
await commandRunner.WaitForRunAsync();
await service.StopAsync(CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-validation:local"));
Assert.True(commandRunner.RunCancellationWasObserved);
}
[Fact]
public async Task HostedServiceUsesApplicationValidationRuntimeInsteadOfNamedProfileOverride()
{
var commandRunner = new BlockingCommandRunner();
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var defaultOptions = CreateValidationOptions("meeting-assistant-pyannote-default:local");
var namedOptions = CreateValidationOptions("meeting-assistant-pyannote-named:local");
var service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(
[
new LaunchProfile("english", namedOptions),
new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, defaultOptions)
]),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
await commandRunner.WaitForRunAsync();
await service.StopAsync(CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-default:local"));
Assert.DoesNotContain(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-named:local"));
}
[Fact]
public async Task HostedServiceDoesNotWarmIdentityValidationWhenResemblyzerIsSelected()
{
var commandRunner = new BlockingCommandRunner();
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var options = CreateValidationOptions("meeting-assistant-pyannote-validation:local");
options.SpeakerIdentification.Resemblyzer.Enabled = true;
var service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(options),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(CancellationToken.None);
await Task.Delay(TimeSpan.FromMilliseconds(50));
await service.StopAsync(CancellationToken.None);
Assert.Empty(commandRunner.Commands);
}
private static MeetingAssistantOptions CreateValidationOptions(string image)
{
return new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" },
SpeakerIdentification =
{
PyannoteValidation =
{
Enabled = true,
Diarization =
{
DockerCommand = "docker",
Image = image,
ModelsFolder = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-tests",
Guid.NewGuid().ToString("N"),
"models"),
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
}
}
}
};
}
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
{
private readonly IReadOnlyList<LaunchProfile> profiles;
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
: this([new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options)])
{
}
public FakeLaunchProfileOptionsProvider(IReadOnlyList<LaunchProfile> profiles)
{
this.profiles = profiles;
}
public LaunchProfile GetRequiredProfile(string? name)
{
var profileName = string.IsNullOrWhiteSpace(name)
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
: name;
return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return profiles;
}
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
{
return [];
}
}
private sealed class BlockingCommandRunner : ICommandRunner
{
private readonly TaskCompletionSource runStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public bool RunCancellationWasObserved { get; private set; }
public Task WaitForRunAsync()
{
return runStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public async Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
if (arguments.Contains("inspect"))
{
return new CommandResult(0, "image-id", "");
}
runStarted.TrySetResult();
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
RunCancellationWasObserved = true;
throw;
}
return new CommandResult(0, "", "");
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}