From c56e14d8fb559abcc73fc7c2c83533cd32cb5320 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 1 Sep 2026 20:47:39 +0200 Subject: Keep a media process and its exit state usable by the caller that started it --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 86 +++++++++++++---- .../Encoder/ProcessWrapperTests.cs | 103 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 0ddd378352..f64fd73763 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1152,6 +1152,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; } + catch (InvalidOperationException) + { + // The process finished before its priority could be lowered. That says nothing + // about whether the platform allows it, so keep the capability for the next one. + } catch (Exception ex) { _canSetProcessPriority = false; @@ -1361,12 +1366,20 @@ namespace MediaBrowser.MediaEncoding.Encoder return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction; } - private sealed class ProcessWrapper : IDisposable + internal sealed class ProcessWrapper : IDisposable { private readonly MediaEncoder _mediaEncoder; + // The exit event is raised on the thread pool, so it writes the state below while the + // caller that started the process is reading it. + private readonly Lock _exitLock = new(); + private bool _disposed = false; + private bool _hasExited; + + private int? _exitCode; + public ProcessWrapper(Process process, MediaEncoder mediaEncoder) { Process = process; @@ -1376,49 +1389,84 @@ namespace MediaBrowser.MediaEncoding.Encoder public Process Process { get; } - public bool HasExited { get; private set; } + // The exit event can lag behind the wait that returned, so ask the process rather than + // report one that has exited as still running. + public bool HasExited => ReadExitState().HasExited; + + // As above: rather than report no exit code for a process that has one. + public int? ExitCode => ReadExitState().ExitCode; - public int? ExitCode { get; private set; } + private (bool HasExited, int? ExitCode) ReadExitState() + { + lock (_exitLock) + { + if (!_hasExited && !_disposed) + { + try + { + if (Process.HasExited) + { + _hasExited = true; + _exitCode = Process.ExitCode; + } + } + catch (InvalidOperationException) + { + // No process is associated with this object, or it was disposed from + // under us - ObjectDisposedException derives from this one. + } + } + + return (_hasExited, _exitCode); + } + } private void OnProcessExited(object sender, EventArgs e) { var process = (Process)sender; - HasExited = true; - - try - { - ExitCode = process.ExitCode; - } - catch + lock (_exitLock) { + _hasExited = true; + + try + { + _exitCode = process.ExitCode; + } + catch + { + } } - DisposeProcess(process); + // Only stop tracking it. The caller that started the process still holds it to read + // its output and its exit code, so disposing it here handed whoever was quickest to + // exit - an ffprobe on a file it rejects outright - an ObjectDisposedException. + Untrack(); } - private void DisposeProcess(Process process) + private void Untrack() { lock (_mediaEncoder._runningProcessesLock) { _mediaEncoder._runningProcesses.Remove(this); } - - process.Dispose(); } public void Dispose() { - if (!_disposed) + lock (_exitLock) { - if (Process is not null) + if (_disposed) { - Process.Exited -= OnProcessExited; - DisposeProcess(Process); + return; } + + _disposed = true; } - _disposed = true; + Process.Exited -= OnProcessExited; + Untrack(); + Process.Dispose(); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs new file mode 100644 index 0000000000..141164815c --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.MediaEncoding.Encoder; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests.Encoder; + +public class ProcessWrapperTests +{ + [Fact] + public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt() + { + using var process = CreateProcess(); + using var exitHandled = new ManualResetEventSlim(false); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has + // already run: whatever it does to the process has happened. + process.Exited += (_, _) => exitHandled.Set(); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited."); + + // The caller still owns the process here. Disposing it from the exit handler handed + // whoever exited quickest an ObjectDisposedException out of these three lines. + var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.Equal("jellyfin", output.Trim()); + + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitState_IsReadableBeforeTheExitEventArrives() + { + using var process = CreateProcess(); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + // The exit event is raised on the thread pool and can lag behind the wait that just + // returned, so neither of these may depend on it having arrived. + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitCode_SurvivesDisposal() + { + using var process = CreateProcess(); + var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + var exitCode = wrapper.ExitCode; + wrapper.Dispose(); + + Assert.Equal(exitCode, wrapper.ExitCode); + Assert.True(wrapper.HasExited); + } + + private static MediaEncoder CreateEncoder() + => new( + Mock.Of>(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + new ConfigurationBuilder().Build(), + Mock.Of()); + + // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that + // rejects a file outright - the process that used to win the race against its own caller. + private static Process CreateProcess() + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3") + : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\""); + + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + + return new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + } +} -- cgit v1.2.3