using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml.Serialization; using Emby.Server.Implementations; using Emby.Server.Implementations.QuickConnect; using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; using Jellyfin.Server.Migrations.Stages; using Jellyfin.Server.ServerSetupApp; using Jellyfin.Server.Tests.HighAvailability; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.QuickConnect; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using StackExchange.Redis; using Xunit; namespace Jellyfin.Server.Tests.QuickConnect; /// /// Brings the server up the way Program does - the real host over , the /// startup and core migrations, then - to pin down what /// a pod does when the quick connect store it is configured against cannot be reached. /// [Trait("Category", "RequiresDocker")] [Collection("JellyfinSectionConfiguration")] public sealed class QuickConnectStartupTests : IAsyncLifetime { private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString"; private const string FfmpegNoValidationVariable = "JELLYFIN_FFMPEG__NOVALIDATION"; private const string DeadStore = "127.0.0.1:1,abortConnect=false,connectTimeout=250,connectRetry=0,syncTimeout=250"; private const string EagerDeadStore = "127.0.0.1:1,connectTimeout=250,connectRetry=0,syncTimeout=250"; private RedisTestServer _redis = null!; /// public async ValueTask InitializeAsync() { Environment.SetEnvironmentVariable(FfmpegNoValidationVariable, "true"); _redis = await RedisTestServer.StartAsync().ConfigureAwait(false); } /// public async ValueTask DisposeAsync() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null); Environment.SetEnvironmentVariable(FfmpegNoValidationVariable, null); await _redis.DisposeAsync().ConfigureAwait(false); } /// /// A store that stays away past the probe's deadline stops the server coming up, so the outage is /// visible where the server is started instead of arriving later as a failure on every request that /// needs the store. The connection string carries the abortConnect=false a deployment uses, so /// the multiplexer connects lazily and only a real read settles whether the store can be served. /// [Fact] public void StoreDownPastTheDeadline_StopsStartup() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, DeadStore); using var server = new StartupHarness(); Assert.ThrowsAny(() => server.Services); Assert.Contains( server.CriticalEntries, entry => entry.Contains("Quick connect", StringComparison.Ordinal) && entry.Contains("UNREACHABLE", StringComparison.Ordinal) && entry.Contains("valkey", StringComparison.Ordinal)); } /// /// Three of the four connection strings the chart documents leave abortConnect at its default, /// which connects eagerly, so the multiplexer is what fails and it fails while the store is being /// built rather than on a read. The probe has to retry the build as well as the read and end on the /// same message, not let a bare out. /// /// /// The core initialisation migrations are skipped here because JellyfinMigrationService takes /// an IBackupService eagerly, which reaches the multiplexer through the library manager, so on /// this shape they fail before the probe is reached at all. That ordering is a separate problem from /// what the probe does when it runs. /// [Fact] public void EagerlyConnectingStoreDownPastTheDeadline_StopsStartupWithTheSameMessage() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, EagerDeadStore); using var server = new StartupHarness(runCoreInitialisationMigrations: false); Assert.ThrowsAny(() => server.Services); Assert.Contains( server.CriticalEntries, entry => entry.Contains("Quick connect", StringComparison.Ordinal) && entry.Contains("UNREACHABLE", StringComparison.Ordinal) && entry.Contains("valkey", StringComparison.Ordinal)); } /// /// A store that is away when the probe first reads it but back inside the deadline lets the server /// come up. A running instance already rides out a valkey blip; a starting one has to as well, or a /// rollout is hostage to valkey restarting at the same time. /// /// A representing the asynchronous operation. [Fact] public async Task StoreBackInsideTheDeadline_StartsAnyway() { await using var proxy = RedisFaultProxy.Start(_redis.ConnectionString); Environment.SetEnvironmentVariable(RedisConnectionStringVariable, proxy.ConnectionString); using var server = new StartupHarness(() => { proxy.Cut(); _ = Task.Run(async () => { await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false); proxy.Restore(); }); }); Assert.IsType(server.Services.GetRequiredService()); Assert.Contains(server.WarningEntries, entry => entry.Contains("not reachable yet", StringComparison.Ordinal)); Assert.Empty(server.CriticalEntries); } /// /// A start that failed has to look like a failure to whatever supervises the process. The server runs /// as its own process here because the exit code is not observable anywhere else, and in a container /// it must not sit on the setup server for ten minutes before getting there. /// /// A representing the asynchronous operation. [Fact] public async Task StoreDownPastTheDeadline_ExitsNonZero() { var root = Path.Combine(Path.GetTempPath(), "jellyfin-quickconnect-exit", Path.GetRandomFileName()); var configDirectory = Path.Combine(root, "config"); Directory.CreateDirectory(configDirectory); Directory.CreateDirectory(Path.Combine(root, "cache")); WriteNetworkConfiguration(configDirectory); var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, WorkingDirectory = AppContext.BaseDirectory }; foreach (var argument in new[] { "exec", Path.Combine(AppContext.BaseDirectory, "jellyfin.dll"), "--datadir", root, "--cachedir", Path.Combine(root, "cache"), "--nowebclient" }) { startInfo.ArgumentList.Add(argument); } startInfo.Environment[RedisConnectionStringVariable] = DeadStore; startInfo.Environment[FfmpegNoValidationVariable] = "true"; startInfo.Environment["DOTNET_RUNNING_IN_CONTAINER"] = "true"; try { var (exitCode, output) = await RunToCompletionAsync(startInfo, TimeSpan.FromMinutes(5)); Assert.Contains("UNREACHABLE", output, StringComparison.Ordinal); Assert.Equal(1, exitCode); } finally { TryDelete(root); } } /// /// A reachable store lets the server come up, and the quick connect it comes up with holds its /// requests in the shared store every instance reads. /// /// A representing the asynchronous operation. [Fact] public async Task ReachableStore_StartsAndServesQuickConnect() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, _redis.ConnectionString); using var server = new StartupHarness(); Assert.IsType(server.Services.GetRequiredService()); var quickConnect = server.Services.GetRequiredService(); var request = await quickConnect.TryConnect(NewAuthorizationInfo()); Assert.Equal(request.Code, (await quickConnect.CheckRequestStatus(request.Secret)).Code); await using var redis = await _redis.ConnectAsync(); Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret)); } /// /// Without a connection string the deployment is single-instance, and it starts on the process-local /// store upstream uses. /// /// A representing the asynchronous operation. [Fact] public async Task NoConnectionString_StartsOnTheProcessLocalStore() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null); using var server = new StartupHarness(); Assert.IsType(server.Services.GetRequiredService()); var quickConnect = server.Services.GetRequiredService(); var request = await quickConnect.TryConnect(NewAuthorizationInfo()); Assert.Equal(request.Code, (await quickConnect.CheckRequestStatus(request.Secret)).Code); } private static AuthorizationInfo NewAuthorizationInfo() => new AuthorizationInfo { DeviceId = Guid.NewGuid().ToString("N"), Device = "Living Room TV", Client = "Jellyfin Web", Version = "1.0.0" }; // The setup server binds before anything else runs, so the spawned server gets a port of its own. private static void WriteNetworkConfiguration(string configDirectory) { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); var configuration = new NetworkConfiguration { InternalHttpPort = port, PublicHttpPort = port, EnableHttps = false, AutoDiscovery = false }; using var writer = new StreamWriter(Path.Combine(configDirectory, "network.xml")); new XmlSerializer(typeof(NetworkConfiguration)).Serialize(writer, configuration); } private static async Task<(int ExitCode, string Output)> RunToCompletionAsync(ProcessStartInfo startInfo, TimeSpan timeout) { var output = new StringBuilder(); using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; process.OutputDataReceived += (_, e) => Append(output, e.Data); process.ErrorDataReceived += (_, e) => Append(output, e.Data); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var cancellation = new CancellationTokenSource(timeout); try { await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false); } catch (OperationCanceledException) { process.Kill(true); throw new TimeoutException($"The server did not exit within {timeout}. Output:\n{output}"); } return (process.ExitCode, output.ToString()); } private static void Append(StringBuilder output, string? line) { if (line is not null) { lock (output) { output.AppendLine(line); } } } private static void TryDelete(string path) { try { Directory.Delete(path, true); } catch (IOException) { // A temporary directory left behind is not worth failing a test over. } } private sealed class StartupHarness : WebApplicationFactory { private readonly ConcurrentBag _disposables = new(); private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries = new(); private readonly Action? _beforeInitializeServices; private readonly bool _runCoreInitialisationMigrations; private readonly string _root = Path.Combine( Path.GetTempPath(), "jellyfin-quickconnect-startup", Path.GetRandomFileName()); static StartupHarness() { StartupHelpers.PerformStaticInitialization(); } public StartupHarness(Action? beforeInitializeServices = null, bool runCoreInitialisationMigrations = true) { _beforeInitializeServices = beforeInitializeServices; _runCoreInitialisationMigrations = runCoreInitialisationMigrations; } public IReadOnlyCollection CriticalEntries => Messages(LogLevel.Critical); public IReadOnlyCollection WarningEntries => Messages(LogLevel.Warning); protected override IHostBuilder CreateHostBuilder() => new HostBuilder(); protected override void ConfigureWebHost(IWebHostBuilder builder) { var commandLineOpts = new StartupOptions(); Directory.CreateDirectory(Path.Combine(_root, "logs")); Directory.CreateDirectory(Path.Combine(_root, "config")); Directory.CreateDirectory(Path.Combine(_root, "cache")); Directory.CreateDirectory(Path.Combine(_root, "jellyfin-web")); var appPaths = new ServerApplicationPaths( _root, Path.Combine(_root, "logs"), Path.Combine(_root, "config"), Path.Combine(_root, "cache"), Path.Combine(_root, "jellyfin-web")); StartupHelpers.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths); ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging .SetMinimumLevel(LogLevel.Warning) .AddProvider(new RecordingProvider(_entries))); _disposables.Add(loggerFactory); var appHost = new CoreAppHost(appPaths, loggerFactory, commandLineOpts, startupConfig); _disposables.Add(appHost); builder.ConfigureServices(services => appHost.Init(services)) .ConfigureWebHostBuilder(appHost, startupConfig, appPaths, NullLogger.Instance) .ConfigureAppConfiguration((context, configuration) => configuration .SetBasePath(appPaths.ConfigurationDirectoryPath) .AddInMemoryCollection(Emby.Server.Implementations.ConfigurationOptions.DefaultConfiguration) .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig())) .ConfigureServices(services => services.RegisterStartupLogger()); } protected override IHost CreateHost(IHostBuilder builder) { var host = builder.Build(); try { var appHost = (CoreAppHost)host.Services.GetRequiredService(); appHost.ServiceProvider = host.Services; var appPaths = (ServerApplicationPaths)host.Services.GetRequiredService(); var configuration = host.Services.GetRequiredService(); Program.ApplyStartupMigrationAsync(appPaths, configuration, new StartupOptions()).GetAwaiter().GetResult(); if (_runCoreInitialisationMigrations) { Program.ApplyCoreMigrationsAsync(host.Services, JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult(); } _beforeInitializeServices?.Invoke(); appHost.InitializeServices(configuration).GetAwaiter().GetResult(); Program.ApplyCoreMigrationsAsync(host.Services, JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult(); host.Start(); return host; } catch { // The factory only owns the host once this returns, so a failed start disposes it here // or the multiplexer it holds outlives the test. host.Dispose(); throw; } } protected override void Dispose(bool disposing) { base.Dispose(disposing); foreach (var disposable in _disposables) { disposable.Dispose(); } _disposables.Clear(); TryDelete(_root); } private IReadOnlyCollection Messages(LogLevel level) => _entries.Where(entry => entry.Level == level).Select(entry => entry.Message).ToArray(); private sealed class RecordingProvider : ILoggerProvider { private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries; public RecordingProvider(ConcurrentQueue<(LogLevel Level, string Message)> entries) { _entries = entries; } public ILogger CreateLogger(string categoryName) => new RecordingLogger(_entries); public void Dispose() { } private sealed class RecordingLogger : ILogger { private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries; public RecordingLogger(ConcurrentQueue<(LogLevel Level, string Message)> entries) { _entries = entries; } public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (IsEnabled(logLevel)) { _entries.Enqueue((logLevel, formatter!(state, exception))); } } } } } }