Commit c36bd60f authored by Alessio Parma's avatar Alessio Parma

command timeout and redirection

parent e17c7a36
......@@ -33,6 +33,7 @@ namespace PommaLabs.Thumbnailer.Models.Configurations
public sealed class SecurityConfiguration
{
private List<ApiKeyConfiguration> _apiKeys = new List<ApiKeyConfiguration>();
private TimeSpan _commandTimeout = TimeSpan.FromSeconds(60);
private long _maxFileDownloadSizeInBytes = 512 * 1024 * 1024;
private long _maxFileUploadSizeInBytes = 256 * 1024 * 1024;
......@@ -63,6 +64,18 @@ public List<ApiKeyConfiguration> ApiKeys
set => _apiKeys = value ?? _apiKeys;
}
/// <summary>
/// How long low level commands (e.g. "magick" calls) should last before being
/// interrupted. Defaults to 60 seconds and it cannot be greater than 10 minutes.
/// </summary>
public TimeSpan CommandTimeout
{
get => _commandTimeout;
set => _commandTimeout = (value > TimeSpan.FromMinutes(10))
? throw new ArgumentOutOfRangeException(nameof(CommandTimeout), "Command timeout must be less than or equal to ten minutes")
: value;
}
/// <summary>
/// How many bytes are allowed for remote downloads. Defaults to 64 MB.
/// </summary>
......
......@@ -36,12 +36,9 @@ public interface ICommandManager
/// </summary>
/// <param name="commandName">Command name.</param>
/// <param name="commandArgs">Command arguments.</param>
/// <param name="outputPath">
/// Where command output should be stored. If null, it will be ignored.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RunCommandAsync(
string commandName, string commandArgs,
string? outputPath, CancellationToken cancellationToken);
CancellationToken cancellationToken);
}
}
......@@ -27,6 +27,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using PommaLabs.Thumbnailer.Models.Configurations;
using PommaLabs.Thumbnailer.Models.Exceptions;
using PommaLabs.Thumbnailer.Services.Stores.TempFiles;
......@@ -38,6 +39,7 @@ namespace PommaLabs.Thumbnailer.Services.Managers.Command
public sealed class SystemCommandManager : ICommandManager
{
private readonly ILogger<SystemCommandManager> _logger;
private readonly SecurityConfiguration _securityConfiguration;
private readonly ITempFileStore _tempFileStore;
/// <summary>
......@@ -45,46 +47,64 @@ public sealed class SystemCommandManager : ICommandManager
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="tempFileStore">Temporary file store.</param>
/// <param name="securityConfiguration">Security configuration.</param>
public SystemCommandManager(
ILogger<SystemCommandManager> logger,
ITempFileStore tempFileStore)
ITempFileStore tempFileStore,
SecurityConfiguration securityConfiguration)
{
_logger = logger;
_tempFileStore = tempFileStore;
_securityConfiguration = securityConfiguration;
}
/// <inheritdoc/>
public async Task RunCommandAsync(
string commandName, string commandArgs,
string? outputPath, CancellationToken cancellationToken)
CancellationToken cancellationToken)
{
_logger.LogInformation("{CommandName} command will be run with following arguments: {CommandArgs}", commandName, commandArgs);
// When we use evaluator helper script, the real command name is the first word of the arguments.
var realCommandName = (commandName == "evaluator") ? commandArgs.Split(' ')[0] : commandName;
_logger.LogInformation("{CommandName} command will be run with following arguments: {CommandArgs}", realCommandName, commandArgs);
using var command = Process.Start(new ProcessStartInfo(commandName, commandArgs)
{
WorkingDirectory = _tempFileStore.RootDirectory,
UseShellExecute = false,
RedirectStandardOutput = (outputPath != null),
RedirectStandardError = true,
});
if (outputPath != null)
var timeout = (int)_securityConfiguration.CommandTimeout.TotalMilliseconds;
if (!command.WaitForExit(timeout))
{
_logger.LogDebug("An output path has been specified, {CommandName} output will be written into: {OutputPath}", commandName, outputPath);
try
{
command.Kill();
}
catch (InvalidOperationException)
{
// The command already finished by itself.
goto WaitAgain;
}
using var outputStream = File.OpenWrite(outputPath);
await command.StandardOutput.BaseStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false);
_logger.LogWarning("A timeout occurred while running {CommandName} with following arguments: {CommandArgs}. Configured timeout: {Timeout}", realCommandName, commandArgs, timeout);
throw new CommandException($"External command \"{realCommandName}\" exceeded the timeout of {timeout} milliseconds and it was killed", command.ExitCode);
}
WaitAgain:
command.WaitForExit();
// From the docs of Process.WaitForExit(int): when standard output has been redirected
// to asynchronous event handlers, it is possible that output processing will not have
// completed when this method returns. To ensure that asynchronous event handling has
// been completed, call the WaitForExit() overload that takes no parameter after
// receiving a true from this overload.
if (command.ExitCode != 0)
{
var commandError = await command.StandardError.ReadToEndAsync().ConfigureAwait(false);
_logger.LogError("An error occurred while running {CommandName} with following arguments: {CommandArgs}. Command error: {CommandError}",
commandName, commandArgs, commandError);
throw new CommandException($"External command \"{commandName}\" raised an error", command.ExitCode);
_logger.LogError("An error occurred while running {CommandName} with following arguments: {CommandArgs}. Command error: {CommandError}", realCommandName, commandArgs, commandError);
throw new CommandException($"External command \"{realCommandName}\" raised an error", command.ExitCode);
}
}
}
......
......@@ -21,9 +21,7 @@
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using PommaLabs.MimeTypes;
......@@ -66,15 +64,12 @@ public sealed class ConcreteOptimizationManager : IOptimizationManager
/// <inheritdoc/>
public async Task<TempFileDTO> OptimizeImageAsync(TempFileDTO file, CancellationToken cancellationToken)
{
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var plugin = s_lossyPlugins[file.ContentType];
var commandName = isWindows ? "imagemin.cmd" : "imagemin";
var commandArgs = $"--plugin {plugin} {(isWindows ? file.Path.Replace("\\", "/", StringComparison.OrdinalIgnoreCase) : file.Path)}";
var optimized = await _tempFileStore.GetTempFileAsync(file.ContentType, cancellationToken).ConfigureAwait(false);
await _commandManager.RunCommandAsync(commandName, commandArgs, optimized.Path, cancellationToken).ConfigureAwait(false);
await _commandManager.RunCommandAsync(
"evaluator", $"imagemin --plugin {plugin} {file.Path} > {optimized.Path}",
cancellationToken).ConfigureAwait(false);
return optimized;
}
......
......@@ -110,13 +110,13 @@ public sealed class ConcreteThumbnailManager : IThumbnailManager
await _commandManager.RunCommandAsync(
"magick", $"-quiet -background {background} -colorspace sRGB -density 96 {extra} {file.Path}[0] -flatten -shave {shavePx}x{shavePx} -thumbnail {widthPx}x{heightPx} {thumbnail.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
if (fill)
{
await _commandManager.RunCommandAsync(
"magick", $"-quiet {thumbnail.Path} -background none -gravity center -extent {widthPx}x{heightPx} {thumbnail.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
return thumbnail;
......@@ -129,7 +129,7 @@ private async Task ConvertHtmlToImageAsync(TempFileDTO file, TempFileDTO image,
const int ZoomFactor = 4;
await _commandManager.RunCommandAsync(
"wkhtmltoimage", $"{WkhtmlCommonFlags} --quality 100 --zoom {ZoomFactor} --width {widthPx * ZoomFactor} {file.Path} {image.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
catch (CommandException ex) when (ex.ExitCode == 1 && new FileInfo(image.Path).Length > 0)
{
......@@ -146,7 +146,7 @@ private async Task ConvertHtmlToPdfAsync(TempFileDTO file, TempFileDTO pdf, Canc
{
await _commandManager.RunCommandAsync(
"wkhtmltopdf", $"{WkhtmlCommonFlags} --zoom 2 --dpi 96 {file.Path} {pdf.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
catch (CommandException ex) when (ex.ExitCode == 1 && new FileInfo(pdf.Path).Length > 0)
{
......@@ -165,7 +165,7 @@ private async Task ConvertOfficeToPdfAsync(TempFileDTO file, TempFileDTO pdf, Ca
{
await _commandManager.RunCommandAsync(
"unoconv", $"--export=PageRange=1-1 --format=pdf --output={pdf.Path} --user-profile={tempProfileDirectory} {file.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
finally
{
......@@ -180,14 +180,14 @@ private async Task ConvertTextToHtmlAsync(TempFileDTO file, TempFileDTO html, Ca
{
await _commandManager.RunCommandAsync(
"pygmentize", $"-o {html.Path} -O full=true,nobackground=true {file.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
private async Task ConvertVideoToImageAsync(TempFileDTO file, TempFileDTO image, CancellationToken cancellationToken)
{
await _commandManager.RunCommandAsync(
"ffmpeg", $"-y -v panic -i {file.Path} -vf thumbnail -frames:v 1 {image.Path}",
default, cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false);
}
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment