From 9f4c857b239575a5f84650c99b8a15120880cc32 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 26 Sep 2026 22:11:03 +1000 Subject: [PATCH] test(session): cover the maintenance sweeps and capabilities routing --- .../SessionManager/SessionManagerTests.cs | 2 +- .../SessionDirectoryReplicaTests.cs | 220 +++++++++++++++++- 2 files changed, 210 insertions(+), 12 deletions(-) diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index 5228c94154..8848dcc2ea 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -223,7 +223,7 @@ public class SessionManagerTests var victimSession = await LogSessionActivity(sessionManager, victim); var attackerSession = await LogSessionActivity(sessionManager, attacker); - Assert.Throws(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities())); + await Assert.ThrowsAsync(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities())); } private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users) diff --git a/tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs b/tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs index 9a6aadcc8b..6b306e10d5 100644 --- a/tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs +++ b/tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs @@ -3,12 +3,16 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.PostgreSQL; using Jellyfin.Server.Implementations.Devices; @@ -18,11 +22,14 @@ using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; using Microsoft.EntityFrameworkCore; @@ -480,11 +487,15 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime var recorded = await ReadOwnerEpoch(session.Id, cancellationToken); var allocated = await _directory.AllocateConnectionEpochAsync(session.Id, cancellationToken); + var other = await _directory.AllocateConnectionEpochAsync(session.Id + "-other", cancellationToken); - // A counter the store owns, not a reading of any replica's clock. - Assert.Equal(1, recorded); - Assert.Equal(2, allocated); - Assert.Equal(1, await _directory.AllocateConnectionEpochAsync(session.Id + "-other", cancellationToken)); + // A per-session counter the store hands out in order, orders of magnitude below any tick count, + // so it cannot be a reading of a replica's clock. + Assert.True(recorded > 0); + Assert.True(allocated > recorded); + Assert.True(other > 0); + Assert.True(other < await _directory.AllocateConnectionEpochAsync(session.Id + "-other", cancellationToken)); + Assert.True(allocated < TimeSpan.TicksPerSecond); } /// @@ -570,6 +581,157 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime await WaitUntil(() => !session.AdditionalUsers.Any(i => i.UserId.Equals(_guest.Id)), cancellationToken); } + /// + /// The idle sweep runs on every replica but stops playback and rewrites the resume position. A + /// replica holding a copy it does not own sees a check-in that froze when reports started routing + /// away, so an unguarded sweep would stop a film the other replica is still playing. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task IdleSweepOnANonOwner_LeavesTheOwnersPlaybackAlone() + { + var cancellationToken = TestContext.Current.CancellationToken; + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var events = new Mock(); + events.Setup(i => i.PublishAsync(It.IsAny())) + .Callback(args => stopped.TrySetResult(args)) + .Returns(Task.CompletedTask); + + var movie = new Movie { Id = Guid.NewGuid(), Name = "Live Movie" }; + var libraryManager = new Mock(); + libraryManager.Setup(i => i.GetItemById(movie.Id)).Returns(movie); + + var userDataManager = new Mock(); + userDataManager.Setup(i => i.GetUserData(It.IsAny(), It.IsAny())).Returns(new UserItemData { Key = "test" }); + + await using var replicaA = CreateReplica( + "pod-a", + userDataManager: userDataManager.Object, + libraryManager: libraryManager.Object, + eventManager: events.Object); + await using var replicaB = CreateReplica("pod-b"); + + var owned = await Request(replicaA, "device-idle-sweep"); + owned.AddController(new RecordingSessionController()); + await replicaA.OnSessionControllerConnected(owned); + + await replicaA.OnPlaybackStart(new PlaybackStartInfo + { + SessionId = owned.Id, + ItemId = movie.Id, + MediaSourceId = movie.Id.ToString("N", CultureInfo.InvariantCulture), + Item = new BaseItemDto { Id = movie.Id, Name = movie.Name }, + PositionTicks = 0 + }); + + // The copy a non-owner is left with: it still remembers a now playing item, and its check-in + // froze the moment reports started going to the owner instead. + var copy = await Request(replicaB, "device-idle-sweep"); + copy.NowPlayingItem = new BaseItemDto { Id = movie.Id, Name = movie.Name }; + copy.StartAutomaticProgress(new PlaybackProgressInfo { IsPaused = true, PositionTicks = 123456789 }); + copy.StopAutomaticProgress(); + copy.LastPlaybackCheckIn = DateTime.UtcNow.AddHours(-1); + + InvokeSweep(replicaB, "CheckForIdlePlayback"); + + await Assert.ThrowsAsync(() => stopped.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)); + + Assert.Equal(movie.Name, owned.NowPlayingItem?.Name); + Assert.Equal(movie.Name, Single(await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken), owned.Id).NowPlayingItem?.Name); + userDataManager.Verify( + i => i.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), UserDataSaveReason.PlaybackFinished, It.IsAny()), + Times.Never); + + // The same sweep on the owner does stop it, so the assertions above are not vacuous. + owned.LastPlaybackCheckIn = DateTime.UtcNow.AddHours(-1); + InvokeSweep(replicaA, "CheckForIdlePlayback"); + + await stopped.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken); + + Assert.Null(owned.NowPlayingItem); + } + + /// + /// The inactive sweep decides from the local copy's paused state and then writes a real Stop to + /// whichever replica holds the connection. A replica that only holds a copy has no business + /// stopping the session the other one is serving. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InactiveSweepOnANonOwner_DoesNotStopTheOwnersSession() + { + var cancellationToken = TestContext.Current.CancellationToken; + var configuration = new ServerConfiguration { InactiveSessionThreshold = 1 }; + + await using var replicaA = CreateReplica("pod-a", configuration: configuration); + await using var replicaB = CreateReplica("pod-b", configuration: configuration); + + var owned = await Request(replicaA, "device-inactive-sweep"); + var controller = new RecordingSessionController(); + owned.AddController(controller); + await replicaA.OnSessionControllerConnected(owned); + + owned.NowPlayingItem = new BaseItemDto { Id = Guid.NewGuid(), Name = "Paused Movie" }; + owned.PlayState.IsPaused = true; + owned.LastPausedDate = DateTime.UtcNow.AddHours(-1); + + var copy = await Request(replicaB, "device-inactive-sweep"); + copy.NowPlayingItem = owned.NowPlayingItem; + copy.PlayState.IsPaused = true; + copy.LastPausedDate = DateTime.UtcNow.AddHours(-1); + + InvokeSweep(replicaB, "CheckForInactiveSteams"); + + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + + Assert.False(controller.HasMessage); + + // The same sweep on the owner does write the Stop, so the assertion above is not vacuous. + InvokeSweep(replicaA, "CheckForInactiveSteams"); + + var (messageType, _) = await controller.WaitForMessageAsync(cancellationToken); + Assert.Equal(SessionMessageType.Playstate, messageType); + } + + /// + /// Without sticky sessions a device posts its capabilities to either replica, and a session is only + /// offered for remote control while the replica that publishes it knows they support media control. + /// A report kept by the replica that served it drops the device from the cast list everywhere. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CapabilitiesReportedToTheNonOwner_KeepTheDeviceControllable() + { + var cancellationToken = TestContext.Current.CancellationToken; + _user.SetPermission(PermissionKind.EnableAllDevices, true); + + await using var replicaA = CreateReplica("pod-a"); + await using var replicaB = CreateReplica("pod-b"); + + var session = await Request(replicaA, "device-capabilities"); + session.AddController(new RecordingSessionController()); + await replicaA.OnSessionControllerConnected(session); + + // The web client posts its capabilities to whichever replica the load balancer picked. + await Request(replicaB, "device-capabilities"); + await replicaB.ReportCapabilities(string.Empty, session.Id, new ClientCapabilities + { + PlayableMediaTypes = [MediaType.Video], + SupportedCommands = [GeneralCommandType.DisplayMessage], + SupportsMediaControl = true, + SupportsPersistentIdentifier = true + }); + + Assert.True(session.SupportsRemoteControl); + + await WaitUntilAsync( + async () => (await ControllableIds(replicaB, cancellationToken)).Contains(session.Id), + cancellationToken); + + Assert.Contains(session.Id, await ControllableIds(replicaA, cancellationToken)); + Assert.Contains(session.Id, await ControllableIds(replicaB, cancellationToken)); + } + /// /// A replica that dies stops refreshing its entries, and the sessions it held have to leave the /// directory rather than linger in every other replica's session list forever. @@ -633,6 +795,21 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime } } + private static async Task WaitUntilAsync(Func> condition, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!await condition()) + { + Assert.True(DateTime.UtcNow < deadline, "The expected change never arrived."); + await Task.Delay(100, cancellationToken); + } + } + + private static void InvokeSweep(SessionManager replica, string name) + => typeof(SessionManager) + .GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(replica, [null]); + private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource) { var optionsBuilder = new DbContextOptionsBuilder(); @@ -648,7 +825,19 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime private Task Request(SessionManager replica, string deviceId) => replica.LogSessionActivity(AppName, AppVersion, deviceId, DeviceName, RemoteEndPoint, _user); - private SessionManager CreateReplica(string podId, SessionDirectoryOptions? options = null, ILogger? logger = null) + private async Task> ControllableIds(SessionManager replica, CancellationToken cancellationToken) + => (await replica.GetSessions(_user.Id, null, null, _user.Id, false, cancellationToken)) + .Select(i => i.Id ?? string.Empty) + .ToList(); + + private SessionManager CreateReplica( + string podId, + SessionDirectoryOptions? options = null, + ILogger? logger = null, + IUserDataManager? userDataManager = null, + ILibraryManager? libraryManager = null, + IEventManager? eventManager = null, + ServerConfiguration? configuration = null) { options ??= new SessionDirectoryOptions { EntryTtlSeconds = 60, RefreshIntervalSeconds = 3600 }; @@ -657,7 +846,7 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime Options.Create(options), NullLogger.Instance); - return CreateReplica(podId, options, directory, CreateBus(podId, options), logger); + return CreateReplica(podId, options, directory, CreateBus(podId, options), logger, userDataManager, libraryManager, eventManager, configuration); } private SessionManager CreateReplica(string podId, ISessionDirectory directory) @@ -666,7 +855,16 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime private SessionManager CreateReplica(string podId, ISessionDirectory directory, IPodMessageBus bus) => CreateReplica(podId, new SessionDirectoryOptions(), directory, bus, null); - private SessionManager CreateReplica(string podId, SessionDirectoryOptions options, ISessionDirectory directory, IPodMessageBus bus, ILogger? logger) + private SessionManager CreateReplica( + string podId, + SessionDirectoryOptions options, + ISessionDirectory directory, + IPodMessageBus bus, + ILogger? logger, + IUserDataManager? userDataManager = null, + ILibraryManager? libraryManager = null, + IEventManager? eventManager = null, + ServerConfiguration? configuration = null) { var userManager = new Mock(); userManager.Setup(i => i.GetUserById(_user.Id)).Returns(_user); @@ -676,14 +874,14 @@ public sealed class SessionDirectoryReplicaTests : IAsyncLifetime appHost.SetupGet(i => i.SystemId).Returns("server-" + podId); var configurationManager = new Mock(); - configurationManager.SetupGet(i => i.Configuration).Returns(new ServerConfiguration()); + configurationManager.SetupGet(i => i.Configuration).Returns(configuration ?? new ServerConfiguration()); return new SessionManager( logger ?? NullLogger.Instance, - Mock.Of(), - Mock.Of(), + eventManager ?? Mock.Of(), + userDataManager ?? Mock.Of(), configurationManager.Object, - Mock.Of(), + libraryManager ?? Mock.Of(), userManager.Object, Mock.Of(), Mock.Of(),