report a peer's port change locally without rewriting it
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

Keep IConfigurationManager source-compatible for plugin implementers.
This commit is contained in:
2026-09-21 00:46:16 +10:00
parent a9d6c749fb
commit 483c739fb1
4 changed files with 153 additions and 18 deletions
+50 -17
View File
@@ -792,6 +792,43 @@ namespace Emby.Server.Implementations
}
}
/// <summary>
/// Works out what a configuration update means for the ports this process bound at startup.
/// </summary>
/// <param name="boundHttpPort">The HTTP port this process is bound to.</param>
/// <param name="boundHttpsPort">The HTTPS port this process is bound to.</param>
/// <param name="configuredHttpPort">The HTTP port the shared configuration now carries.</param>
/// <param name="configuredHttpsPort">The HTTPS port the shared configuration now carries.</param>
/// <param name="isPortAuthorized">Whether the shared configuration still marks the port as authorized.</param>
/// <param name="isApplyingRemoteInvalidation">Whether this update is another instance's write being applied.</param>
/// <returns>What the update requires of this instance.</returns>
internal static PortChangeOutcome EvaluatePortChange(
int boundHttpPort,
int boundHttpsPort,
int configuredHttpPort,
int configuredHttpsPort,
bool isPortAuthorized,
bool isApplyingRemoteInvalidation)
{
// Nothing is bound yet, so nothing has gone stale.
if (boundHttpPort == 0 || boundHttpsPort == 0)
{
return default;
}
if (configuredHttpPort == boundHttpPort && configuredHttpsPort == boundHttpsPort)
{
return default;
}
// Whoever wrote the change, this process is still listening on a port the configuration no
// longer names, so the pending restart is reported either way. The authorization flag belongs
// to the instance that made the change: it cleared the flag along with the port, and clearing
// it again here would write shared configuration on that instance's behalf and announce it a
// second time.
return new PortChangeOutcome(true, isPortAuthorized && !isApplyingRemoteInvalidation);
}
/// <summary>
/// Called when [configuration updated].
/// </summary>
@@ -799,28 +836,24 @@ namespace Emby.Server.Implementations
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnConfigurationUpdated(object sender, EventArgs e)
{
var requiresRestart = false;
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
// Don't do anything if these haven't been set yet, and don't clear the authorization flag on
// behalf of another instance: it already wrote that flag along with the port change, so
// repeating the save here only races it.
if (HttpPort != 0 && HttpsPort != 0 && !ConfigurationInvalidationContext.IsApplyingRemoteInvalidation)
{
// Need to restart if ports have changed
if (networkConfiguration.InternalHttpPort != HttpPort
|| networkConfiguration.InternalHttpsPort != HttpsPort)
{
if (ConfigurationManager.Configuration.IsPortAuthorized)
{
ConfigurationManager.Configuration.IsPortAuthorized = false;
ConfigurationManager.SaveConfiguration();
var portChange = EvaluatePortChange(
HttpPort,
HttpsPort,
networkConfiguration.InternalHttpPort,
networkConfiguration.InternalHttpsPort,
ConfigurationManager.Configuration.IsPortAuthorized,
ConfigurationInvalidationContext.IsApplyingRemoteInvalidation);
requiresRestart = true;
}
}
if (portChange.ClearsPortAuthorization)
{
ConfigurationManager.Configuration.IsPortAuthorized = false;
ConfigurationManager.SaveConfiguration();
}
var requiresRestart = portChange.RequiresRestart;
if (ValidateSslCertificate(networkConfiguration))
{
requiresRestart = true;
@@ -0,0 +1,14 @@
namespace Emby.Server.Implementations
{
/// <summary>
/// What a configuration update carrying different ports requires of the instance reading it.
/// </summary>
/// <param name="RequiresRestart">
/// Whether this process is still bound to a port the shared configuration no longer names, and so has
/// to report a pending restart.
/// </param>
/// <param name="ClearsPortAuthorization">
/// Whether this instance is the one that has to clear the port authorization flag and save it.
/// </param>
internal readonly record struct PortChangeOutcome(bool RequiresRestart, bool ClearsPortAuthorization);
}
@@ -90,8 +90,15 @@ namespace MediaBrowser.Common.Configuration
/// Drops the locally cached copy of configuration another instance has written to the shared
/// configuration directory, so the next read reloads it, and raises the local update event.
/// </summary>
/// <remarks>
/// An implementation predating the invalidation bus keeps the default, which reports that it
/// cannot drop its cache rather than quietly leaving it stale. The caller applying a remote
/// notice treats that as a failed apply and logs it.
/// </remarks>
/// <param name="key">The named configuration key, or <c>null</c> for the system configuration.</param>
void InvalidateCachedConfiguration(string? key);
/// <exception cref="NotSupportedException">The implementation cannot drop its cached configuration.</exception>
void InvalidateCachedConfiguration(string? key)
=> throw new NotSupportedException(GetType().Name + " cannot drop configuration cached from the shared configuration directory, so writes by other instances will not be picked up.");
}
public static class ConfigurationManagerExtensions
@@ -0,0 +1,81 @@
using Emby.Server.Implementations;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// The decision <c>ApplicationHost.OnConfigurationUpdated</c> makes about a port change. The ports this
/// process bound are fixed for its lifetime and the pending-restart flag is per-process, so an instance
/// applying another instance's port change still has to notice its own binding went stale - while leaving
/// the authorization write, and the notice that follows it, to the instance that made the change.
/// </summary>
public static class ApplicationHostPortChangeTests
{
/// <summary>
/// The local case, unchanged: clear the authorization flag and report the pending restart.
/// </summary>
[Fact]
public static void LocalPortChange_ClearsAuthorizationAndRequiresRestart()
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, true, false);
Assert.True(outcome.RequiresRestart);
Assert.True(outcome.ClearsPortAuthorization);
}
/// <summary>
/// The cross-instance case: the peer wrote the new port and cleared the flag with it, so this
/// instance must not write, but it is still listening on the old port and has to say so.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public static void RemotePortChange_RequiresRestartWithoutWriting(bool isPortAuthorized)
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, isPortAuthorized, true);
Assert.True(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// A second update while a port change is already pending must not write the flag again, and the
/// binding is still stale.
/// </summary>
[Fact]
public static void LocalPortChange_WithAuthorizationAlreadyCleared_RequiresRestartWithoutWriting()
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, false, false);
Assert.True(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// An update that leaves the ports alone is not a port change, whoever wrote it.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public static void UnchangedPorts_DoNothing(bool isApplyingRemoteInvalidation)
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 8096, 8920, true, isApplyingRemoteInvalidation);
Assert.False(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// Nothing is decided before the ports have been bound.
/// </summary>
[Theory]
[InlineData(0, 8920)]
[InlineData(8096, 0)]
public static void UnboundPorts_DoNothing(int boundHttpPort, int boundHttpsPort)
{
var outcome = ApplicationHost.EvaluatePortChange(boundHttpPort, boundHttpsPort, 9096, 9920, true, false);
Assert.False(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
}