From 930d493b8c0a2735537922dd1d53a69829d7e6f5 Mon Sep 17 00:00:00 2001 From: Sergey Nechaev <6499856+snechaev@users.noreply.github.com> Date: Wed, 22 Oct 2025 20:32:30 +0200 Subject: [PATCH 1/5] Add test to verify unexpected exception on FFProbe operations cancellation. Ref.: #594 --- FFMpegCore.Test/FFProbeTests.cs | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/FFMpegCore.Test/FFProbeTests.cs b/FFMpegCore.Test/FFProbeTests.cs index a702eed..16f3b4c 100644 --- a/FFMpegCore.Test/FFProbeTests.cs +++ b/FFMpegCore.Test/FFProbeTests.cs @@ -285,4 +285,61 @@ public class FFProbeTests var info = FFProbe.Analyse(TestResources.Mp4Video, customArguments: "-headers \"Hello: World\""); Assert.AreEqual(3, info.Duration.Seconds); } + + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task Parallel_FFProbe_Cancellation_Should_Throw_Only_OperationCanceledException() + { + // Warm up FFMpegCore environment + Helpers.FFProbeHelper.VerifyFFProbeExists(GlobalFFOptions.Current); + + var mp4 = TestResources.Mp4Video; + if (!File.Exists(mp4)) + { + Assert.Inconclusive($"Test video not found: {mp4}"); + return; + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); + var token = cts.Token; + using var semaphore = new SemaphoreSlim(Environment.ProcessorCount, Environment.ProcessorCount); + var tasks = Enumerable.Range(0, 50).Select(x => Task.Run(async () => + { + await semaphore.WaitAsync(token); + try + { + var analysis = await FFProbe.AnalyseAsync(mp4, cancellationToken: token); + return analysis; + } + finally + { + semaphore.Release(); + } + }, token)).ToList(); + + // Wait for 2 tasks to finish, then cancel all + await Task.WhenAny(tasks); + await Task.WhenAny(tasks); + await cts.CancelAsync(); + cts.Dispose(); + + var exceptions = new List(); + foreach (var task in tasks) + { + try + { + await task; + } + catch (Exception e) + { + exceptions.Add(e); + } + } + + Assert.IsNotEmpty(exceptions, "No exceptions were thrown on cancellation. Test was useless. " + + ".Try adjust cancellation timings to make cancellation at the moment, when ffprobe is still running."); + + // Check that all exceptions are OperationCanceledException + CollectionAssert.AllItemsAreInstancesOfType(exceptions, typeof(OperationCanceledException)); + } } From b863f5d19e876cb3527e05436b3ef4377406dcd0 Mon Sep 17 00:00:00 2001 From: Sergey Nechaev <6499856+snechaev@users.noreply.github.com> Date: Wed, 22 Oct 2025 20:41:26 +0200 Subject: [PATCH 2/5] FFProbe: Do not throw FFMpegException if cancellation was requested. Throw OperationCancelledException in this case to provide more uniform and expected behavior. Fixes #594 --- FFMpegCore/FFProbe/FFProbe.cs | 11 +++++++---- FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index e199376..ba08346 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -84,7 +84,7 @@ public static class FFProbe var instance = PrepareStreamAnalysisInstance(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); - ThrowIfExitCodeNotZero(result); + ThrowIfExitCodeNotZero(result, cancellationToken); return ParseOutput(result); } @@ -123,7 +123,7 @@ public static class FFProbe { var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); - ThrowIfExitCodeNotZero(result); + ThrowIfExitCodeNotZero(result, cancellationToken); return ParseOutput(result); } @@ -150,7 +150,7 @@ public static class FFProbe } var result = await task.ConfigureAwait(false); - ThrowIfExitCodeNotZero(result); + ThrowIfExitCodeNotZero(result, cancellationToken); pipeArgument.Post(); return ParseOutput(result); @@ -212,8 +212,11 @@ public static class FFProbe } } - private static void ThrowIfExitCodeNotZero(IProcessResult result) + private static void ThrowIfExitCodeNotZero(IProcessResult result, CancellationToken cancellationToken = default) { + // if cancellation requested, then we are not interested in the exit code, just throw the cancellation exception + // to get consistent and expected behavior. + cancellationToken.ThrowIfCancellationRequested(); if (result.ExitCode != 0) { var message = $"ffprobe exited with non-zero exit-code ({result.ExitCode} - {string.Join("\n", result.ErrorData)})"; diff --git a/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs b/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs index ef7140a..6682e94 100644 --- a/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs +++ b/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs @@ -13,6 +13,6 @@ public static class ProcessArgumentsExtensions public static async Task StartAndWaitForExitAsync(this ProcessArguments processArguments, CancellationToken cancellationToken = default) { using var instance = processArguments.Start(); - return await instance.WaitForExitAsync(cancellationToken); + return await instance.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } } From e44611bd25360df725d9331ca6bbffef3e71ad72 Mon Sep 17 00:00:00 2001 From: Sergey Nechaev <6499856+snechaev@users.noreply.github.com> Date: Wed, 22 Oct 2025 21:23:30 +0200 Subject: [PATCH 3/5] Additional test to verify that FFProbeHelper still throws FFMpegException when FFProbe exits with non-zero code and no cancellation was requested. Ref.: #594 --- FFMpegCore.Test/FFProbeTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/FFMpegCore.Test/FFProbeTests.cs b/FFMpegCore.Test/FFProbeTests.cs index 16f3b4c..ee837bf 100644 --- a/FFMpegCore.Test/FFProbeTests.cs +++ b/FFMpegCore.Test/FFProbeTests.cs @@ -1,4 +1,5 @@ -using FFMpegCore.Test.Resources; +using FFMpegCore.Exceptions; +using FFMpegCore.Test.Resources; namespace FFMpegCore.Test; @@ -342,4 +343,13 @@ public class FFProbeTests // Check that all exceptions are OperationCanceledException CollectionAssert.AllItemsAreInstancesOfType(exceptions, typeof(OperationCanceledException)); } + + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task FFProbe_Should_Throw_FFMpegException_When_Exits_With_Non_Zero_Code() + { + var input = TestResources.SrtSubtitle; //non media file + await Assert.ThrowsAsync(async () => await FFProbe.AnalyseAsync(input, + cancellationToken: TestContext.CancellationToken, customArguments: "--some-invalid-argument")); + } } From 560c791802d3c7b799aced9ab9dca2f7b76e6968 Mon Sep 17 00:00:00 2001 From: Sergey Nechaev <6499856+snechaev@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:06:55 +0200 Subject: [PATCH 4/5] Update the `ThrowIfExitCodeNotZero()` to check the exit code before handling cancellation. This preserves the original semantics and contract (throw only if the ffprobe exits with a non-zero code). --- FFMpegCore/FFProbe/FFProbe.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index ba08346..5f275d2 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -214,11 +214,11 @@ public static class FFProbe private static void ThrowIfExitCodeNotZero(IProcessResult result, CancellationToken cancellationToken = default) { - // if cancellation requested, then we are not interested in the exit code, just throw the cancellation exception - // to get consistent and expected behavior. - cancellationToken.ThrowIfCancellationRequested(); if (result.ExitCode != 0) { + // if cancellation requested, then we are not interested in the exit code, just throw the cancellation exception + // to get consistent and expected behavior. + cancellationToken.ThrowIfCancellationRequested(); var message = $"ffprobe exited with non-zero exit-code ({result.ExitCode} - {string.Join("\n", result.ErrorData)})"; throw new FFMpegException(FFMpegExceptionType.Process, message, null, string.Join("\n", result.ErrorData)); } From 67af2aa01dff975fd5c328a8a2025b6a109fd687 Mon Sep 17 00:00:00 2001 From: Sergey Nechaev <6499856+snechaev@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:35:36 +0100 Subject: [PATCH 5/5] Move cancellation check outside of the `ThrowIfExitCodeNotZero()` and call it separately in all the async code paths. --- FFMpegCore/FFProbe/FFProbe.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index 5f275d2..164ea72 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -84,7 +84,8 @@ public static class FFProbe var instance = PrepareStreamAnalysisInstance(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); - ThrowIfExitCodeNotZero(result, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfExitCodeNotZero(result); return ParseOutput(result); } @@ -123,7 +124,8 @@ public static class FFProbe { var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); - ThrowIfExitCodeNotZero(result, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfExitCodeNotZero(result); return ParseOutput(result); } @@ -150,7 +152,8 @@ public static class FFProbe } var result = await task.ConfigureAwait(false); - ThrowIfExitCodeNotZero(result, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfExitCodeNotZero(result); pipeArgument.Post(); return ParseOutput(result); @@ -212,13 +215,10 @@ public static class FFProbe } } - private static void ThrowIfExitCodeNotZero(IProcessResult result, CancellationToken cancellationToken = default) + private static void ThrowIfExitCodeNotZero(IProcessResult result) { if (result.ExitCode != 0) { - // if cancellation requested, then we are not interested in the exit code, just throw the cancellation exception - // to get consistent and expected behavior. - cancellationToken.ThrowIfCancellationRequested(); var message = $"ffprobe exited with non-zero exit-code ({result.ExitCode} - {string.Join("\n", result.ErrorData)})"; throw new FFMpegException(FFMpegExceptionType.Process, message, null, string.Join("\n", result.ErrorData)); }