using System; using System.IO; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Serialization; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Configuration; /// /// Applying an invalidation re-raises the same update events a local save raises, and some consumers of /// those events answer an update by writing - RecordingsManager creating the recording folders for /// the livetv key, ApplicationHost clearing IsPortAuthorized on a port change. The /// instance that did not write must not repeat those writes, and no write it is induced into must reach /// the bus. /// public sealed class RemoteInvalidationApplyTests : IDisposable { private readonly string _root; /// /// Initializes a new instance of the class. /// public RemoteInvalidationApplyTests() { _root = Path.Combine(Path.GetTempPath(), "jf-config-apply-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } /// public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } } /// /// A consumer that knows nothing about the bus - a plugin, or anything reached transitively from one - /// can answer an applied invalidation by writing. That write must not become a notice of its own. /// /// A representing the asynchronous operation. [Fact] public async Task NamedInvalidation_InducingAWriteOnTheReceiver_DoesNotPublishBack() { var fabric = new FakeInvalidationBusFabric(); var instanceA = CreateInstance(fabric, "pod-a"); var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); var receivedByA = 0; instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA)); var writesByB = 0; instanceB.NamedConfigurationUpdated += (_, e) => { // One shot: the induced write raises the event again on this instance. if (Interlocked.Increment(ref writesByB) > 1) { return; } var configuration = (NetworkConfiguration)instanceB.GetConfiguration(e.Key); configuration.PublishedServerUriBySubnet = ["10.0.0.0/8=example"]; instanceB.SaveConfiguration(e.Key, configuration); }; var updated = instanceA.GetNetworkConfiguration(); updated.EnableRemoteAccess = false; instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated); Assert.Equal(0, receivedByA); // The point of the bus still holds: B is not left on its stale copy. Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess); } /// /// The shape of RecordingsManager: a consumer that answers a named configuration update by /// writing has to be able to tell that the write was another instance's, and skip it. /// /// A representing the asynchronous operation. [Fact] public async Task NamedInvalidation_WithAWriteTriggeringConsumer_DoesNotDuplicateTheWrite() { var fabric = new FakeInvalidationBusFabric(); var instanceA = CreateInstance(fabric, "pod-a"); var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); var receivedByA = 0; instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA)); var writesByB = 0; instanceB.NamedConfigurationUpdated += (_, e) => { if (ConfigurationInvalidationContext.IsApplyingRemoteInvalidation) { return; } Interlocked.Increment(ref writesByB); }; var updated = instanceA.GetNetworkConfiguration(); updated.EnableRemoteAccess = false; instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated); Assert.Equal(0, writesByB); Assert.Equal(0, receivedByA); Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess); // A read-only consumer is still told, which is what the invalidation exists for. var refreshes = 0; instanceB.NamedConfigurationUpdated += (_, _) => Interlocked.Increment(ref refreshes); updated.EnableRemoteAccess = true; instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated); Assert.Equal(1, refreshes); } /// /// The ApplicationHost.IsPortAuthorized class of consumer: the system configuration event is /// queued rather than raised inline, so the fix has to survive the hop onto the thread pool. /// /// A representing the asynchronous operation. [Fact] public async Task SystemInvalidation_InducingAQueuedWriteOnTheReceiver_DoesNotPublishBack() { var fabric = new FakeInvalidationBusFabric(); var instanceA = CreateInstance(fabric, "pod-a"); var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); var receivedByA = 0; instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA)); var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var handled = 0; instanceB.ConfigurationUpdated += (_, _) => { if (Interlocked.Increment(ref handled) > 1) { return; } instanceB.Configuration.IsPortAuthorized = false; instanceB.SaveConfiguration(); applied.TrySetResult(); }; instanceA.Configuration.QuickConnectAvailable = false; instanceA.SaveConfiguration(); await applied.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); Assert.Equal(0, receivedByA); Assert.False(instanceB.Configuration.QuickConnectAvailable); } private async Task CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId) { var instance = CreateInstance(fabric, originId); var subscriber = new ConfigurationInvalidationSubscriber( instance.InvalidationBus, instance, NullLogger.Instance); await subscriber.StartAsync(CancellationToken.None); return instance; } private ServerConfigurationManager CreateInstance(FakeInvalidationBusFabric fabric, string originId) { var paths = new ServerApplicationPaths( Ensure("data"), Ensure("log"), Ensure("config"), Ensure("cache"), Ensure("web")); var manager = new ServerConfigurationManager(paths, NullLoggerFactory.Instance, new MyXmlSerializer()) { InvalidationBus = fabric.Connect(originId) }; manager.AddParts([new NetworkConfigurationFactory()]); return manager; } private string Ensure(string name) { var path = Path.Combine(_root, name); Directory.CreateDirectory(path); return path; } }