Merge pull request #17794 from Shadowghost/plugin-install-fixes

Better handle timeouts on plugin operations
This commit is contained in:
Cody Robibero
2026-09-06 12:47:45 -04:00
committed by GitHub
3 changed files with 72 additions and 11 deletions
@@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
}
catch (TimeoutException ex)
{
// One slow download must not abort the updates for the remaining plugins.
_logger.LogError(ex, "Error downloading {Name}", package.Name);
}
catch (InvalidDataException ex)
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
@@ -11,7 +11,6 @@ using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
@@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates
public class InstallationManager : IInstallationManager
{
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
// Budget for the whole package download. The response headers are already bounded by the
// HttpClient timeout; this covers reading the package body, which can be large and slow.
private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>
/// The logger.
@@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates
IServerConfigurationManager config,
IPluginManager pluginManager)
{
_currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
_completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
_currentInstallations = [];
_completedInstallationsInternal = [];
_logger = logger;
_applicationHost = appHost;
@@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates
_applicationHost.NotifyPendingRestart();
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (linkedToken.IsCancellationRequested)
{
// Only an actually cancelled token is a cancellation.
lock (_currentInstallationsLock)
{
_currentInstallations.Remove(tuple);
@@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates
}
catch (Exception ex)
{
_logger.LogError(ex, "Package installation failed");
_logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version);
lock (_currentInstallationsLock)
{
@@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
// ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers
// the whole download; the package gets the longer budget below instead.
using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
downloadTokenSource.CancelAfter(PackageDownloadTimeout);
var downloadToken = downloadTokenSource.Token;
var buffer = new MemoryStream();
await using (buffer.ConfigureAwait(false))
{
try
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
// The package is read twice, for the checksum and for the extraction, so it has
// to be buffered: the response stream is not seekable.
await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
// Either our budget above or the HttpClient timeout ran out.
throw new TimeoutException(
$"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.",
ex);
}
buffer.Position = 0;
Stream stream = buffer;
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
cancellationToken.ThrowIfCancellationRequested();
+30 -1
View File
@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
@@ -23,16 +25,22 @@ public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IPluginManager _pluginManager;
/// <summary>
/// Initializes a new instance of the <see cref="PackageController"/> class.
/// </summary>
/// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
/// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
public PackageController(
IInstallationManager installationManager,
IServerConfigurationManager serverConfigurationManager,
IPluginManager pluginManager)
{
_installationManager = installationManager;
_serverConfigurationManager = serverConfigurationManager;
_pluginManager = pluginManager;
}
/// <summary>
@@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController
[FromRoute, Required] string name,
[FromQuery] Guid? assemblyGuid)
{
// Plugins bundled with the server are not published to any repository, so querying
// the configured repositories for them can only ever fail, and does so slowly.
if (IsBundledPlugin(name, assemblyGuid))
{
return NotFound();
}
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var result = _installationManager.FilterPackages(
packages,
@@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController
[FromQuery] string? version,
[FromQuery] string? repositoryUrl)
{
if (IsBundledPlugin(name, assemblyGuid))
{
return NotFound();
}
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
@@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController
_serverConfigurationManager.SaveConfiguration();
return NoContent();
}
private bool IsBundledPlugin(string name, Guid? assemblyGuid)
{
var plugin = assemblyGuid is Guid id && !id.IsEmpty()
? _pluginManager.GetPlugin(id)
: _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return plugin?.Instance?.CanUninstall == false;
}
}